Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9af2b2a
fix(desktop): retain update transactions until recovery is healthy
callumalpass Sep 13, 2026
271bbe1
fix(auth): confirm local revocation only after exact policy acknowled…
callumalpass Sep 13, 2026
0e18d73
fix(daemon): expose canonical readiness and fail closed on bootstrap …
callumalpass Sep 13, 2026
b285418
fix(relay): classify reconnect failures and bound retry pacing
callumalpass Sep 13, 2026
f1a7320
fix(mirrors): rearm transient credential reads in the existing scheduler
callumalpass Sep 13, 2026
569bab6
fix(desktop): gate startup and updates on canonical daemon readiness
callumalpass Sep 13, 2026
19e9c3d
fix(desktop): retain resource inventories and action errors during re…
callumalpass Sep 13, 2026
c54aec7
fix(editor): resume pending note mutations with their original identi…
callumalpass Sep 13, 2026
8ceb558
docs(recovery): document ownership, qualification gates and measured …
callumalpass Sep 13, 2026
7a8a430
test(relay): await committed readiness before asserting reconnect rou…
callumalpass Sep 13, 2026
f80cb19
fix(editor): settle definitively rejected recovery without losing drafts
callumalpass Sep 13, 2026
4112aaf
fix(editor): use SDK settlement rather than probe error classification
callumalpass Sep 13, 2026
ecc2d45
fix(desktop): preserve verified rollback admission across restarts
callumalpass Sep 13, 2026
5f82353
Merge current main and reconcile recovery migration and protocol iden…
callumalpass Sep 14, 2026
d3fd97a
Prepare beta100 exact recovery and truthful health release
callumalpass Sep 14, 2026
0be25a2
fix(desktop): keep action error text distinct from its dismiss control
callumalpass Sep 14, 2026
71bc568
test(editor): observe collection freeze independently of autosave
callumalpass Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.1.0-beta.99"
version = "0.1.0-beta.100"
edition = "2021"
license = "MIT"
repository = "https://github.com/mdbase-dev/mdbase-connect"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@mdbase/connect-desktop",
"productName": "mdbase connect",
"version": "0.1.0-beta.99",
"version": "0.1.0-beta.100",
"description": "Connect applications to authorized mdbase collections.",
"author": "mdbase",
"private": true,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/scripts/build-main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ await build({
"src/main/main.ts",
"src/main/preload.ts",
"src/main/agent-startup.ts",
"src/main/boot-gate.ts",
"src/main/daemon-lifecycle.ts",
"src/main/deep-link.ts",
"src/main/editor-url.ts",
Expand Down
35 changes: 22 additions & 13 deletions apps/desktop/src/main/agent-startup.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { presentReadiness, type AgentReadiness } from "../shared/readiness";
export { presentReadiness } from "../shared/readiness";

export interface AgentPing {
pong: boolean;
ready?: boolean;
readiness?: AgentReadiness;
}

export interface AgentStartupOptions {
ping(timeoutMs: number): Promise<AgentPing>;
launch(): Promise<void>;
endpointIsUnavailable(error: unknown): boolean;
incompatibleDaemon(error: unknown): boolean;
expectedVersion: string;
readinessTimeoutMs?: number;
pollIntervalMs?: number;
}

class ReadinessError extends Error {}

function isReady(ping: AgentPing, options: AgentStartupOptions): boolean {
const health = presentReadiness(ping.readiness, options.expectedVersion);
if (!ping.pong || health.state === "attention") throw new ReadinessError(health.label);
return health.state === "ready";
}

const terminal = (error: unknown, options: AgentStartupOptions) =>
error instanceof ReadinessError || options.incompatibleDaemon(error);
const delay = (durationMs: number) =>
new Promise<void>((resolve) => setTimeout(resolve, durationMs));

Expand All @@ -20,10 +35,9 @@ export async function waitForAgentReady(options: AgentStartupOptions): Promise<v
const pollIntervalMs = options.pollIntervalMs ?? 100;
while (Date.now() < deadline) {
try {
const ping = await options.ping(500);
if (ping.ready !== false) return;
if (isReady(await options.ping(500), options)) return;
} catch (error) {
if (options.incompatibleDaemon(error)) throw error;
if (terminal(error, options)) throw error;
// The process may still be binding its local endpoint.
}
await delay(pollIntervalMs);
Expand All @@ -33,29 +47,24 @@ export async function waitForAgentReady(options: AgentStartupOptions): Promise<v

export async function ensureAgentReady(options: AgentStartupOptions): Promise<void> {
try {
const ping = await options.ping(400);
if (ping.ready !== false) return;
if (isReady(await options.ping(400), options)) return;
return waitForAgentReady(options);
} catch (error) {
if (options.incompatibleDaemon(error)) throw error;
if (terminal(error, options)) throw error;
if (!options.endpointIsUnavailable(error)) return waitForAgentReady(options);
}

try {
await options.launch();
} catch (launchError) {
try {
const ping = await options.ping(500);
if (ping.ready !== false) return;
if (isReady(await options.ping(500), options)) return;
} catch (probeError) {
if (options.incompatibleDaemon(probeError)) throw probeError;
if (terminal(probeError, options)) throw probeError;
if (options.endpointIsUnavailable(probeError)) throw launchError;
}
// Starting a persistent service and waiting for its collection scan are
// separate operations. The CLI can time out while the service continues
// initializing, so keep polling an endpoint that is already available.
// A service can still be scanning after the CLI's startup budget expires.
return waitForAgentReady(options);
}

await waitForAgentReady(options);
}
62 changes: 62 additions & 0 deletions apps/desktop/src/main/boot-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
interface BootGateOptions {
initialize(): Promise<void>;
start(): Promise<void>;
blockedReason(): string | null;
}

/** One owner for update recovery, startup and daemon-backed IPC admission. */
export class BootGate {
private initialization: Promise<void> | undefined;
private startup: Promise<void> | undefined;
private installing = false;

constructor(private readonly options: BootGateOptions) {}

private initialize(): Promise<void> {
// A failed boot stays failed until the application restarts. Ordinary IPC
// must not silently bypass a failed persisted update recovery.
return this.initialization ??= this.options.initialize();
}

private assertAdmission(): void {
const reason = this.installing
? "The application update is in progress."
: this.options.blockedReason();
if (reason) throw new Error(reason);
}

async ready(): Promise<void> {
await this.initialize();
this.assertAdmission();
this.startup ??= this.options.start().finally(() => { this.startup = undefined; });
await this.startup;
this.assertAdmission();
}

async request<T>(operation: () => Promise<T>): Promise<T> {
await this.ready();
this.assertAdmission();
return operation();
}

async check<T>(operation: () => Promise<T>): Promise<T> {
await this.initialize();
this.assertAdmission();
return operation();
}

async install<T>(operation: () => Promise<T>): Promise<T> {
if (this.installing) throw new Error("The application update is in progress.");
this.installing = true;
try {
await this.initialize();
// Never race the CLI startup already admitted before installation began.
await this.startup;
const reason = this.options.blockedReason();
if (reason) throw new Error(reason);
return await operation();
} finally {
this.installing = false;
}
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src/main/control-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createConnection } from "node:net";
import { randomUUID } from "node:crypto";

const LOCAL_CONTROL_PROTOCOL_VERSION = 5;
export const LOCAL_CONTROL_PROTOCOL_VERSION = 5;
const MAX_LOCAL_CONTROL_RESPONSE_BYTES = 32 * 1024 * 1024;

export interface ControlResponse<T = unknown> {
Expand Down
31 changes: 29 additions & 2 deletions apps/desktop/src/main/daemon-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
import { execFile as execFileCallback } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);

export async function launchDaemon(binary: string, paths: DaemonPaths, packaged: boolean): Promise<void> {
if (!existsSync(binary)) throw new Error(`Connector runtime is missing: ${binary}`);
await mkdir(paths.stateDir, { recursive: true });
await execFile(binary, daemonCliArguments(paths.target, paths.stateDir, paths.endpoint, ["start"]), {
env: connectCliEnvironment(packaged), timeout: 30_000, windowsHide: true
});
}

export function connectCliEnvironment(
packaged: boolean,
environment: NodeJS.ProcessEnv = process.env
Expand All @@ -9,15 +24,27 @@ export function connectCliEnvironment(
return sanitized;
}

export type DaemonTarget = "installed_service" | "isolated_profile";
export interface DaemonPaths { stateDir: string; endpoint: string; target: DaemonTarget }

export function parseDaemonPaths(value: unknown): DaemonPaths {
const paths = value as { state_dir?: unknown; endpoint?: unknown; target?: unknown } | null;
if (!paths || typeof paths.state_dir !== "string" || typeof paths.endpoint !== "string" ||
(paths.target !== "installed_service" && paths.target !== "isolated_profile")) {
throw new Error("The connector runtime returned invalid path information.");
}
return { stateDir: paths.state_dir, endpoint: paths.endpoint, target: paths.target };
}

export function daemonCliArguments(
packaged: boolean,
target: DaemonTarget,
stateDirectory: string,
endpoint: string,
command: string[],
json = false
): string[] {
return [
...(packaged ? [] : ["--state-dir", stateDirectory, "--endpoint", endpoint]),
...(target === "isolated_profile" ? ["--state-dir", stateDirectory, "--endpoint", endpoint] : []),
...(json ? ["--json"] : []),
"connect",
"daemon",
Expand Down
Loading