Skip to content

Commit ebabfb8

Browse files
committed
Skip unmigratable orgs in the provider service split and fail boot loudly
The boot-rail service split threw out of planMigration when a monolith config had no stored specHash, which failed the whole data-migration run and left the daemon wedged on startup with only 'Unknown error' on stderr (#1403). Route that throw through the existing per-org hardError path so the org is skipped, warned about, and retried on a later run, and warn about skipped orgs even when no org is migratable. A fatal CLI failure now exits the process instead of parking it with the port unbound, so the desktop shows the crash screen instead of an infinite 'Starting executor'. CLI error rendering walks the cause chain so wrapper errors print the underlying message instead of 'Unknown error'.
1 parent 8bc037d commit ebabfb8

6 files changed

Lines changed: 208 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@executor-js/plugin-provider-service-split": patch
3+
"executor": patch
4+
---
5+
6+
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".

apps/cli/src/main.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,14 +1520,45 @@ const parseOptionalJsonObject = (
15201520
Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)),
15211521
);
15221522

1523+
const ownMessage = (value: unknown): string => {
1524+
if (typeof value === "string") return value.trim();
1525+
if (typeof value === "object" && value !== null && "message" in value) {
1526+
const message = (value as { message: unknown }).message;
1527+
if (typeof message === "string") return message.trim();
1528+
}
1529+
return "";
1530+
};
1531+
1532+
/**
1533+
* Render an unknown failure by walking its `cause` chain. Wrapper errors
1534+
* (tagged errors carrying only `{ cause }`) have an empty own message; without
1535+
* descending, a boot failure like a data-migration error printed as literally
1536+
* "Unknown error" (issue #1403). Nested messages that merely repeat their
1537+
* parent are dropped.
1538+
*/
15231539
const formatUnknownMessage = (cause: unknown): string => {
1524-
if (cause instanceof Error) return cause.message;
1525-
if (typeof cause === "string") return cause;
1526-
if (typeof cause === "object" && cause !== null && "message" in cause) {
1527-
const message = cause.message;
1528-
if (typeof message === "string") return message;
1540+
const messages: string[] = [];
1541+
const seen = new Set<unknown>();
1542+
let current: unknown = cause;
1543+
while (current !== null && current !== undefined && !seen.has(current)) {
1544+
seen.add(current);
1545+
const message = ownMessage(current);
1546+
if (message.length > 0 && !messages.some((existing) => existing.includes(message))) {
1547+
messages.push(message);
1548+
}
1549+
current =
1550+
typeof current === "object" && "cause" in current
1551+
? (current as { cause: unknown }).cause
1552+
: undefined;
1553+
}
1554+
if (messages.length === 0) {
1555+
return typeof cause === "string"
1556+
? cause
1557+
: cause instanceof Error
1558+
? cause.message
1559+
: String(cause);
15291560
}
1530-
return String(cause);
1561+
return messages.join("\ncaused by: ");
15311562
};
15321563

15331564
const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => {
@@ -3205,7 +3236,12 @@ const program = (
32053236
} else {
32063237
console.error(renderCliError(cause));
32073238
}
3208-
process.exitCode = 1;
3239+
// Exit hard, not via exitCode: this handler converts the failure into a
3240+
// success, so runMain's teardown never force-exits, and background fibers
3241+
// (the integrations-refresh fork) keep the event loop alive. A daemon
3242+
// that failed boot then lingers with its port unbound, and the desktop
3243+
// waits on the ready sentinel forever (issue #1403).
3244+
process.exit(1);
32093245
}),
32103246
),
32113247
);

packages/plugins/provider-service-split/src/planner.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,89 @@ describe("provider service split migration planner", () => {
519519
expect(plan.summary.hardErrorOrgs).toBe(1);
520520
});
521521

522+
it("records a hard error instead of throwing when a monolith has no specHash", () => {
523+
const plan = planMigration(
524+
input({
525+
integrations: [
526+
integration({
527+
config: {
528+
googleDiscoveryUrls: [
529+
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
530+
],
531+
},
532+
}),
533+
],
534+
collectPolicyErrors: true,
535+
}),
536+
);
537+
538+
expect(plan.orgs[0]?.hardErrors).toEqual([
539+
expect.stringContaining("has no specHash; serving state cannot be migrated"),
540+
]);
541+
expect(plan.summary.hardErrorOrgs).toBe(1);
542+
});
543+
544+
it("a specHash-less monolith only skips its own org", () => {
545+
const plan = planMigration(
546+
input({
547+
integrations: [
548+
integration({
549+
config: {
550+
googleDiscoveryUrls: [
551+
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
552+
],
553+
},
554+
}),
555+
integration({ tenant: "org_2", row_id: "int_2" }),
556+
],
557+
connections: [connection(), connection({ tenant: "org_2", row_id: "conn_2" })],
558+
tools: [
559+
tool("calendar.events.list"),
560+
tool("calendar.events.list", { tenant: "org_2", row_id: "tool_2" }),
561+
],
562+
pluginStorage: [
563+
operation("calendar.events.list"),
564+
operation("calendar.events.list", {
565+
tenant: "org_2",
566+
row_id: "op_2",
567+
}),
568+
],
569+
blobs: [
570+
blob("spec/mono-hash"),
571+
blob("defs/mono-hash"),
572+
blob("spec/mono-hash", { namespace: "o:org_2/google", id: "blob_3" }),
573+
blob("defs/mono-hash", { namespace: "o:org_2/google", id: "blob_4" }),
574+
],
575+
collectPolicyErrors: true,
576+
}),
577+
);
578+
579+
const broken = plan.orgs.find((org) => org.tenant === "org_1");
580+
const healthy = plan.orgs.find((org) => org.tenant === "org_2");
581+
expect(broken?.hardErrors).toHaveLength(1);
582+
expect(healthy?.hardErrors).toEqual([]);
583+
expect(healthy?.integrations.map((row) => row.target.slug)).toEqual(["google_calendar"]);
584+
expect(plan.summary.hardErrorOrgs).toBe(1);
585+
});
586+
587+
it("still throws on a missing specHash outside collectPolicyErrors mode", () => {
588+
expect(() =>
589+
planMigration(
590+
input({
591+
integrations: [
592+
integration({
593+
config: {
594+
googleDiscoveryUrls: [
595+
"https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
596+
],
597+
},
598+
}),
599+
],
600+
}),
601+
),
602+
).toThrow(/has no specHash; serving state cannot be migrated/);
603+
});
604+
522605
it("retargets orphan block policies in boot-rail mode", () => {
523606
const plan = planMigration(
524607
input({

packages/plugins/provider-service-split/src/planner.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,19 @@ export const planMigration = (input: MigrationInput): MigrationPlan => {
10451045
hardErrors.push(error instanceof Error ? error.message : String(error));
10461046
continue;
10471047
}
1048-
const specHash = specHashFor(monolith);
1048+
// Same escape hatch as deriveServices above: under collectPolicyErrors a
1049+
// monolith without a usable specHash becomes a per-org hardError (org
1050+
// skipped, warned, left unstamped) instead of failing the whole plan. An
1051+
// escaped throw here took down every org in the run and, on the local
1052+
// boot rail, prevented the daemon from starting at all (issue #1403).
1053+
let specHash: string;
1054+
try {
1055+
specHash = specHashFor(monolith);
1056+
} catch (error) {
1057+
if (!input.collectPolicyErrors) throw error;
1058+
hardErrors.push(error instanceof Error ? error.message : String(error));
1059+
continue;
1060+
}
10491061
const namespace = pluginBlobNamespace(tenant, monolith.plugin_id);
10501062
const targetNamespace = pluginBlobNamespace(tenant, "openapi");
10511063
const specBlobPresent =

packages/plugins/provider-service-split/src/sqlite.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,59 @@ describe("providerServiceSplitDataMigration", () => {
288288
}),
289289
);
290290

291+
it.effect("skips a specHash-less org intact and still migrates healthy orgs", () =>
292+
Effect.gen(function* () {
293+
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
294+
const client = db.client;
295+
296+
// org_1: a real pre-split Google setup whose config never recorded a
297+
// specHash (issue #1403 — this used to throw out of the planner and
298+
// fail the whole boot migration).
299+
yield* Effect.promise(() =>
300+
insertIntegration(client, {
301+
rowId: "google_row_org_1",
302+
tenant: "org_1",
303+
slug: "google",
304+
pluginId: "google",
305+
config: {
306+
googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
307+
},
308+
}),
309+
);
310+
yield* Effect.promise(() => insertConnection(client, "org_1"));
311+
yield* Effect.promise(() => insertTool(client, "calendar.events.list", "org_1"));
312+
yield* Effect.promise(() => insertOperation(client, "calendar.events.list", "org_1"));
313+
yield* seedCalendarOrg(client, "org_2");
314+
315+
expect(yield* runSqliteDataMigrations(client, [providerServiceSplitDataMigration])).toEqual([
316+
"2026-07-08-provider-service-split",
317+
]);
318+
319+
const integrations = yield* Effect.promise(() =>
320+
client.execute("SELECT tenant, slug, plugin_id FROM integration ORDER BY tenant, slug"),
321+
);
322+
expect(integrations.rows).toEqual([
323+
{ tenant: "org_1", slug: "google", plugin_id: "google" },
324+
{ tenant: "org_2", slug: "google_calendar", plugin_id: "openapi" },
325+
]);
326+
327+
const ledger = yield* Effect.promise(() =>
328+
client.execute("SELECT tenant FROM provider_service_split_org_migration ORDER BY tenant"),
329+
);
330+
expect(ledger.rows).toEqual([{ tenant: "org_2" }]);
331+
332+
const connections = yield* Effect.promise(() =>
333+
client.execute("SELECT tenant, integration FROM connection ORDER BY tenant"),
334+
);
335+
expect(connections.rows).toEqual([
336+
{ tenant: "org_1", integration: "google" },
337+
{ tenant: "org_2", integration: "google_calendar" },
338+
]);
339+
340+
yield* Effect.promise(() => db.close());
341+
}),
342+
);
343+
291344
it.effect("uses the per-org ledger to recover after a mid-run crash", () =>
292345
Effect.gen(function* () {
293346
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));

packages/plugins/provider-service-split/src/sqlite.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -519,17 +519,21 @@ export const runSqliteProviderServiceSplitMigration = (
519519
if (!(yield* tableExists(client, "integration"))) return 0;
520520
const input = yield* readDatabaseInput(client, options);
521521
const plan = planMigration(input);
522+
// Warn about skipped orgs before the early return: when EVERY org has hard
523+
// errors, moved is 0 and the loop below never runs, which made a fully
524+
// skipped migration silent (issue #1403's failure mode after the fix).
525+
for (const org of plan.orgs) {
526+
if (org.completed || org.hardErrors.length === 0) continue;
527+
console.warn(
528+
`provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`,
529+
);
530+
}
522531
const moved = plan.orgs.filter((org) => !org.completed && org.hardErrors.length === 0).length;
523532
if (moved === 0) return 0;
524533

525534
for (const org of plan.orgs) {
526535
if (org.completed) continue;
527-
if (org.hardErrors.length > 0) {
528-
console.warn(
529-
`provider-service-split: skipped org ${org.tenantHash}: ${org.hardErrors.join("; ")}`,
530-
);
531-
continue;
532-
}
536+
if (org.hardErrors.length > 0) continue;
533537
yield* execute(client, "BEGIN");
534538
const applyOne = Effect.gen(function* () {
535539
yield* applyOrg(client, org);

0 commit comments

Comments
 (0)