Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/service-split-boot-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/plugin-provider-service-split": patch
"executor": patch
---

The provider service split boot migration now skips an org whose Google or Microsoft integration cannot be migrated (for example a config without a stored specHash) instead of failing the whole migration and blocking server startup. A daemon that does fail during boot now exits with the underlying error message instead of hanging with a generic "Unknown error".
50 changes: 43 additions & 7 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1520,14 +1520,45 @@ const parseOptionalJsonObject = (
Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),
);

const ownMessage = (value: unknown): string => {
if (typeof value === "string") return value.trim();
if (typeof value === "object" && value !== null && "message" in value) {
const message = (value as { message: unknown }).message;
if (typeof message === "string") return message.trim();
}
return "";
};

/**
* Render an unknown failure by walking its `cause` chain. Wrapper errors
* (tagged errors carrying only `{ cause }`) have an empty own message; without
* descending, a boot failure like a data-migration error printed as literally
* "Unknown error" (issue #1403). Nested messages that merely repeat their
* parent are dropped.
*/
const formatUnknownMessage = (cause: unknown): string => {
if (cause instanceof Error) return cause.message;
if (typeof cause === "string") return cause;
if (typeof cause === "object" && cause !== null && "message" in cause) {
const message = cause.message;
if (typeof message === "string") return message;
const messages: string[] = [];
const seen = new Set<unknown>();
let current: unknown = cause;
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const message = ownMessage(current);
if (message.length > 0 && !messages.some((existing) => existing.includes(message))) {
messages.push(message);
}
current =
typeof current === "object" && "cause" in current
? (current as { cause: unknown }).cause
: undefined;
}
if (messages.length === 0) {
return typeof cause === "string"
? cause
: cause instanceof Error
? cause.message
: String(cause);
}
return String(cause);
return messages.join("\ncaused by: ");
};

const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {
Expand Down Expand Up @@ -3205,7 +3236,12 @@ const program = (
} else {
console.error(renderCliError(cause));
}
process.exitCode = 1;
// Exit hard, not via exitCode: this handler converts the failure into a
// success, so runMain's teardown never force-exits, and background fibers
// (the integrations-refresh fork) keep the event loop alive. A daemon
// that failed boot then lingers with its port unbound, and the desktop
// waits on the ready sentinel forever (issue #1403).
process.exit(1);
}),
),
);
Expand Down
83 changes: 83 additions & 0 deletions packages/plugins/provider-service-split/src/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,89 @@ describe("provider service split migration planner", () => {
expect(plan.summary.hardErrorOrgs).toBe(1);
});

it("records a hard error instead of throwing when a monolith has no specHash", () => {
const plan = planMigration(
input({
integrations: [
integration({
config: {
googleDiscoveryUrls: [
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
],
},
}),
],
collectPolicyErrors: true,
}),
);

expect(plan.orgs[0]?.hardErrors).toEqual([
expect.stringContaining("has no specHash; serving state cannot be migrated"),
]);
expect(plan.summary.hardErrorOrgs).toBe(1);
});

it("a specHash-less monolith only skips its own org", () => {
const plan = planMigration(
input({
integrations: [
integration({
config: {
googleDiscoveryUrls: [
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
],
},
}),
integration({ tenant: "org_2", row_id: "int_2" }),
],
connections: [connection(), connection({ tenant: "org_2", row_id: "conn_2" })],
tools: [
tool("calendar.events.list"),
tool("calendar.events.list", { tenant: "org_2", row_id: "tool_2" }),
],
pluginStorage: [
operation("calendar.events.list"),
operation("calendar.events.list", {
tenant: "org_2",
row_id: "op_2",
}),
],
blobs: [
blob("spec/mono-hash"),
blob("defs/mono-hash"),
blob("spec/mono-hash", { namespace: "o:org_2/google", id: "blob_3" }),
blob("defs/mono-hash", { namespace: "o:org_2/google", id: "blob_4" }),
],
collectPolicyErrors: true,
}),
);

const broken = plan.orgs.find((org) => org.tenant === "org_1");
const healthy = plan.orgs.find((org) => org.tenant === "org_2");
expect(broken?.hardErrors).toHaveLength(1);
expect(healthy?.hardErrors).toEqual([]);
expect(healthy?.integrations.map((row) => row.target.slug)).toEqual(["google_calendar"]);
expect(plan.summary.hardErrorOrgs).toBe(1);
});

it("still throws on a missing specHash outside collectPolicyErrors mode", () => {
expect(() =>
planMigration(
input({
integrations: [
integration({
config: {
googleDiscoveryUrls: [
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
],
},
}),
],
}),
),
).toThrow(/has no specHash; serving state cannot be migrated/);
});

it("retargets orphan block policies in boot-rail mode", () => {
const plan = planMigration(
input({
Expand Down
14 changes: 13 additions & 1 deletion packages/plugins/provider-service-split/src/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,7 +1045,19 @@ export const planMigration = (input: MigrationInput): MigrationPlan => {
hardErrors.push(error instanceof Error ? error.message : String(error));
continue;
}
const specHash = specHashFor(monolith);
// Same escape hatch as deriveServices above: under collectPolicyErrors a
// monolith without a usable specHash becomes a per-org hardError (org
// skipped, warned, left unstamped) instead of failing the whole plan. An
// escaped throw here took down every org in the run and, on the local
// boot rail, prevented the daemon from starting at all (issue #1403).
let specHash: string;
try {
specHash = specHashFor(monolith);
} catch (error) {
if (!input.collectPolicyErrors) throw error;
hardErrors.push(error instanceof Error ? error.message : String(error));
continue;
}
const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);
const targetNamespace = pluginBlobNamespace(tenant, "openapi");
const specBlobPresent =
Expand Down
53 changes: 53 additions & 0 deletions packages/plugins/provider-service-split/src/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,59 @@ describe("providerServiceSplitDataMigration", () => {
}),
);

it.effect("skips a specHash-less org intact and still migrates healthy orgs", () =>
Effect.gen(function* () {
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
const client = db.client;

// org_1: a real pre-split Google setup whose config never recorded a
// specHash (issue #1403 — this used to throw out of the planner and
// fail the whole boot migration).
yield* Effect.promise(() =>
insertIntegration(client, {
rowId: "google_row_org_1",
tenant: "org_1",
slug: "google",
pluginId: "google",
config: {
googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
},
}),
);
yield* Effect.promise(() => insertConnection(client, "org_1"));
yield* Effect.promise(() => insertTool(client, "calendar.events.list", "org_1"));
yield* Effect.promise(() => insertOperation(client, "calendar.events.list", "org_1"));
yield* seedCalendarOrg(client, "org_2");

expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual([
"2026-07-08-provider-service-split",
]);

const integrations = yield* Effect.promise(() =>
client.execute("SELECT tenant, slug, plugin_id FROM integration ORDER BY tenant, slug"),
);
expect(integrations.rows).toEqual([
{ tenant: "org_1", slug: "google", plugin_id: "google" },
{ tenant: "org_2", slug: "google_calendar", plugin_id: "openapi" },
]);

const ledger = yield* Effect.promise(() =>
client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"),
);
expect(ledger.rows).toEqual([{ tenant: "org_2" }]);

const connections = yield* Effect.promise(() =>
client.execute("SELECT tenant, integration FROM connection ORDER BY tenant"),
);
expect(connections.rows).toEqual([
{ tenant: "org_1", integration: "google" },
{ tenant: "org_2", integration: "google_calendar" },
]);

yield* Effect.promise(() => db.close());
}),
);

it.effect("uses the per-org ledger to recover after a mid-run crash", () =>
Effect.gen(function* () {
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
Expand Down
16 changes: 10 additions & 6 deletions packages/plugins/provider-service-split/src/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,17 +519,21 @@ export const runSqliteProviderServiceSplitMigration = (
if (!(yield* tableExists(client, "integration"))) return 0;
const input = yield* readDatabaseInput(client, options);
const plan = planMigration(input);
// Warn about skipped orgs before the early return: when EVERY org has hard
// errors, moved is 0 and the loop below never runs, which made a fully
// skipped migration silent (issue #1403's failure mode after the fix).
for (const org of plan.orgs) {
if (org.completed || org.hardErrors.length === 0) continue;
console.warn(
`provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`,
);
}
const moved = plan.orgs.filter((org) => !org.completed && org.hardErrors.length === 0).length;
if (moved === 0) return 0;

for (const org of plan.orgs) {
if (org.completed) continue;
if (org.hardErrors.length > 0) {
console.warn(
`provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`,
);
continue;
}
if (org.hardErrors.length > 0) continue;
yield* execute(client, "BEGIN");
const applyOne = Effect.gen(function* () {
yield* applyOrg(client, org);
Expand Down
Loading