@vellumai/assistant 0.10.3-dev.202606291342.c7a83ea → 0.10.3-dev.202606291517.f0c68f7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.yaml +60 -0
- package/package.json +1 -1
- package/scripts/generate-openapi.ts +69 -4
- package/src/__tests__/identity-routes.test.ts +113 -25
- package/src/daemon/__tests__/daemon-readiness.test.ts +51 -0
- package/src/daemon/daemon-readiness.ts +33 -0
- package/src/daemon/lifecycle.ts +33 -6
- package/src/persistence/__tests__/db-init-migrations-ok.test.ts +20 -0
- package/src/persistence/db-init.ts +12 -2
- package/src/runtime/routes/identity-routes.ts +31 -12
package/openapi.yaml
CHANGED
|
@@ -12,9 +12,26 @@ paths:
|
|
|
12
12
|
/healthz:
|
|
13
13
|
get:
|
|
14
14
|
operationId: healthz_get
|
|
15
|
+
summary: Liveness probe
|
|
16
|
+
description:
|
|
17
|
+
Trivial liveness/startup probe. Returns { status, version } the instant the HTTP server is up, with zero
|
|
18
|
+
DB/CES/lifecycle access.
|
|
15
19
|
responses:
|
|
16
20
|
"200":
|
|
17
21
|
description: Successful response
|
|
22
|
+
content:
|
|
23
|
+
application/json:
|
|
24
|
+
schema:
|
|
25
|
+
type: object
|
|
26
|
+
properties:
|
|
27
|
+
status:
|
|
28
|
+
type: string
|
|
29
|
+
version:
|
|
30
|
+
type: string
|
|
31
|
+
required:
|
|
32
|
+
- status
|
|
33
|
+
- version
|
|
34
|
+
additionalProperties: false
|
|
18
35
|
/pages/{id}:
|
|
19
36
|
get:
|
|
20
37
|
operationId: pages__id__get
|
|
@@ -30,9 +47,52 @@ paths:
|
|
|
30
47
|
/readyz:
|
|
31
48
|
get:
|
|
32
49
|
operationId: readyz_get
|
|
50
|
+
summary: Readiness probe
|
|
51
|
+
description:
|
|
52
|
+
Strict readiness probe. Returns 503 with { status, ready, notReady } until the daemon's synchronous startup
|
|
53
|
+
latches (isStartupComplete() && isDbReady()) are set, then a stable 200. notReady lists the gates still failing
|
|
54
|
+
("startup", "db"). CES is informational and never gates readiness.
|
|
33
55
|
responses:
|
|
34
56
|
"200":
|
|
35
57
|
description: Successful response
|
|
58
|
+
content:
|
|
59
|
+
application/json:
|
|
60
|
+
schema:
|
|
61
|
+
type: object
|
|
62
|
+
properties:
|
|
63
|
+
status:
|
|
64
|
+
type: string
|
|
65
|
+
ready:
|
|
66
|
+
type: boolean
|
|
67
|
+
notReady:
|
|
68
|
+
type: array
|
|
69
|
+
items:
|
|
70
|
+
type: string
|
|
71
|
+
required:
|
|
72
|
+
- status
|
|
73
|
+
- ready
|
|
74
|
+
- notReady
|
|
75
|
+
additionalProperties: false
|
|
76
|
+
"503":
|
|
77
|
+
description: Not ready — startup incomplete or database not ready.
|
|
78
|
+
content:
|
|
79
|
+
application/json:
|
|
80
|
+
schema:
|
|
81
|
+
type: object
|
|
82
|
+
properties:
|
|
83
|
+
status:
|
|
84
|
+
type: string
|
|
85
|
+
ready:
|
|
86
|
+
type: boolean
|
|
87
|
+
notReady:
|
|
88
|
+
type: array
|
|
89
|
+
items:
|
|
90
|
+
type: string
|
|
91
|
+
required:
|
|
92
|
+
- status
|
|
93
|
+
- ready
|
|
94
|
+
- notReady
|
|
95
|
+
additionalProperties: false
|
|
36
96
|
/v1/acp/{id}/cancel:
|
|
37
97
|
post:
|
|
38
98
|
operationId: acp_by_id_cancel_post
|
package/package.json
CHANGED
|
@@ -216,13 +216,69 @@ async function collectRoutesFromModules(): Promise<RouteEntry[]> {
|
|
|
216
216
|
return routes;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Trivial liveness/startup probe response. `/healthz` is the k8s startup +
|
|
221
|
+
* liveness target and stays intentionally minimal: a static `{ status, version }`
|
|
222
|
+
* answered the instant the HTTP server is up, with zero DB/CES/lifecycle access.
|
|
223
|
+
*/
|
|
224
|
+
const trivialHealthSchema = z.object({
|
|
225
|
+
status: z.string(),
|
|
226
|
+
version: z.string(),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Strict readiness probe response. `/readyz` gates on the daemon's synchronous
|
|
231
|
+
* startup latches (`isStartupComplete() && isDbReady()`), returning 503 with
|
|
232
|
+
* `ready: false` until both are set, then a stable 200. `notReady` lists the
|
|
233
|
+
* gates still failing (`"startup"`, `"db"`). CES is never consulted.
|
|
234
|
+
*/
|
|
235
|
+
const readyzSchema = z.object({
|
|
236
|
+
status: z.string(),
|
|
237
|
+
ready: z.boolean(),
|
|
238
|
+
notReady: z.array(z.string()),
|
|
239
|
+
});
|
|
240
|
+
|
|
219
241
|
/**
|
|
220
242
|
* Top-level routes outside the /v1/ namespace.
|
|
221
243
|
* These are added to the spec separately.
|
|
222
244
|
*/
|
|
223
|
-
const NON_V1_ROUTES: Array<{
|
|
224
|
-
|
|
225
|
-
|
|
245
|
+
const NON_V1_ROUTES: Array<{
|
|
246
|
+
method: string;
|
|
247
|
+
path: string;
|
|
248
|
+
summary?: string;
|
|
249
|
+
description?: string;
|
|
250
|
+
responseBody?: z.ZodType;
|
|
251
|
+
additionalResponses?: Record<
|
|
252
|
+
string,
|
|
253
|
+
{ description: string; schema?: unknown }
|
|
254
|
+
>;
|
|
255
|
+
}> = [
|
|
256
|
+
{
|
|
257
|
+
method: "GET",
|
|
258
|
+
path: "/healthz",
|
|
259
|
+
summary: "Liveness probe",
|
|
260
|
+
description:
|
|
261
|
+
"Trivial liveness/startup probe. Returns { status, version } the instant " +
|
|
262
|
+
"the HTTP server is up, with zero DB/CES/lifecycle access.",
|
|
263
|
+
responseBody: trivialHealthSchema,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
method: "GET",
|
|
267
|
+
path: "/readyz",
|
|
268
|
+
summary: "Readiness probe",
|
|
269
|
+
description:
|
|
270
|
+
"Strict readiness probe. Returns 503 with { status, ready, notReady } until " +
|
|
271
|
+
"the daemon's synchronous startup latches (isStartupComplete() && isDbReady()) " +
|
|
272
|
+
"are set, then a stable 200. notReady lists the gates still failing " +
|
|
273
|
+
'("startup", "db"). CES is informational and never gates readiness.',
|
|
274
|
+
responseBody: readyzSchema,
|
|
275
|
+
additionalResponses: {
|
|
276
|
+
"503": {
|
|
277
|
+
description: "Not ready — startup incomplete or database not ready.",
|
|
278
|
+
schema: readyzSchema,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
226
282
|
{ method: "GET", path: "/pages/{id}" },
|
|
227
283
|
];
|
|
228
284
|
|
|
@@ -322,7 +378,16 @@ function buildSpec(
|
|
|
322
378
|
path: r.path,
|
|
323
379
|
method: r.method,
|
|
324
380
|
endpoint: r.path,
|
|
325
|
-
entry: {
|
|
381
|
+
entry: {
|
|
382
|
+
method: r.method,
|
|
383
|
+
endpoint: r.path,
|
|
384
|
+
...(r.summary ? { summary: r.summary } : {}),
|
|
385
|
+
...(r.description ? { description: r.description } : {}),
|
|
386
|
+
...(r.responseBody ? { responseBody: r.responseBody } : {}),
|
|
387
|
+
...(r.additionalResponses
|
|
388
|
+
? { additionalResponses: r.additionalResponses }
|
|
389
|
+
: {}),
|
|
390
|
+
},
|
|
326
391
|
});
|
|
327
392
|
}
|
|
328
393
|
}
|
|
@@ -22,13 +22,20 @@ mock.module("../util/logger.js", () => ({
|
|
|
22
22
|
}),
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
+
import {
|
|
26
|
+
resetReadinessForTest,
|
|
27
|
+
setDbReady,
|
|
28
|
+
setStartupComplete,
|
|
29
|
+
} from "../daemon/daemon-readiness.js";
|
|
25
30
|
import {
|
|
26
31
|
handleDetailedHealth,
|
|
32
|
+
handleHealth,
|
|
27
33
|
handleReadyz,
|
|
28
34
|
ROUTES,
|
|
29
35
|
} from "../runtime/routes/identity-routes.js";
|
|
30
36
|
import { setCesClient } from "../security/secure-keys.js";
|
|
31
37
|
import { getWorkspaceDir } from "../util/platform.js";
|
|
38
|
+
import { APP_VERSION } from "../version.js";
|
|
32
39
|
import {
|
|
33
40
|
getHatchedSidecarPath,
|
|
34
41
|
resolveHatchedAtReadOnly,
|
|
@@ -196,31 +203,6 @@ describe("identity routes — health endpoint", () => {
|
|
|
196
203
|
setCesClient(undefined);
|
|
197
204
|
});
|
|
198
205
|
|
|
199
|
-
test("readyz returns 200 and logs warning when CES is unavailable", () => {
|
|
200
|
-
const res = handleReadyz();
|
|
201
|
-
expect(res.status).toBe(200);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("readyz returns 200 when CES is connected and ready", () => {
|
|
205
|
-
const mockClient = {
|
|
206
|
-
isReady: () => true,
|
|
207
|
-
close: () => {},
|
|
208
|
-
} as unknown as import("../credential-execution/client.js").CesClient;
|
|
209
|
-
setCesClient(mockClient);
|
|
210
|
-
const res = handleReadyz();
|
|
211
|
-
expect(res.status).toBe(200);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("readyz returns 200 when CES client exists but is not ready", () => {
|
|
215
|
-
const mockClient = {
|
|
216
|
-
isReady: () => false,
|
|
217
|
-
close: () => {},
|
|
218
|
-
} as unknown as import("../credential-execution/client.js").CesClient;
|
|
219
|
-
setCesClient(mockClient);
|
|
220
|
-
const res = handleReadyz();
|
|
221
|
-
expect(res.status).toBe(200);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
206
|
test("/v1/health reports ces.connected=true when CES is ready", async () => {
|
|
225
207
|
const mockClient = {
|
|
226
208
|
isReady: () => true,
|
|
@@ -407,6 +389,112 @@ describe("identity routes — health endpoint", () => {
|
|
|
407
389
|
});
|
|
408
390
|
});
|
|
409
391
|
|
|
392
|
+
describe("identity routes — trivial /healthz liveness probe", () => {
|
|
393
|
+
test("returns 200 with { status: 'ok', version } carrying APP_VERSION", async () => {
|
|
394
|
+
const res = handleHealth();
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
|
|
397
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
398
|
+
expect(body.status).toBe("ok");
|
|
399
|
+
expect(typeof body.version).toBe("string");
|
|
400
|
+
expect(body.version).not.toBe("");
|
|
401
|
+
expect(body.version).toBe(APP_VERSION);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("never touches CES/lifecycle state — a throwing CES client is never consulted", async () => {
|
|
405
|
+
// If the trivial probe touched CES at all this would throw.
|
|
406
|
+
const explodingClient = {
|
|
407
|
+
isReady: () => {
|
|
408
|
+
throw new Error("handleHealth must not access CES");
|
|
409
|
+
},
|
|
410
|
+
close: () => {},
|
|
411
|
+
} as unknown as import("../credential-execution/client.js").CesClient;
|
|
412
|
+
setCesClient(explodingClient);
|
|
413
|
+
|
|
414
|
+
const res = handleHealth();
|
|
415
|
+
expect(res.status).toBe(200);
|
|
416
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
417
|
+
expect(body).toEqual({ status: "ok", version: APP_VERSION });
|
|
418
|
+
|
|
419
|
+
setCesClient(undefined);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("answers with CES uninitialized", async () => {
|
|
423
|
+
setCesClient(undefined);
|
|
424
|
+
const res = handleHealth();
|
|
425
|
+
expect(res.status).toBe(200);
|
|
426
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
427
|
+
expect(body).toEqual({ status: "ok", version: APP_VERSION });
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("identity routes — /readyz readiness gate", () => {
|
|
432
|
+
afterEach(() => {
|
|
433
|
+
resetReadinessForTest();
|
|
434
|
+
setCesClient(undefined);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("returns 503 with notReady:['startup','db'] before startup", async () => {
|
|
438
|
+
resetReadinessForTest();
|
|
439
|
+
const res = handleReadyz();
|
|
440
|
+
expect(res.status).toBe(503);
|
|
441
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
442
|
+
expect(body).toEqual({
|
|
443
|
+
status: "unready",
|
|
444
|
+
ready: false,
|
|
445
|
+
notReady: ["startup", "db"],
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("returns 503 with notReady:['startup'] when db is ready but startup isn't", async () => {
|
|
450
|
+
resetReadinessForTest();
|
|
451
|
+
setDbReady(true);
|
|
452
|
+
const res = handleReadyz();
|
|
453
|
+
expect(res.status).toBe(503);
|
|
454
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
455
|
+
expect(body.ready).toBe(false);
|
|
456
|
+
expect(body.notReady).toEqual(["startup"]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("returns 503 with notReady:['db'] when started but db not ready", async () => {
|
|
460
|
+
resetReadinessForTest();
|
|
461
|
+
setStartupComplete();
|
|
462
|
+
const res = handleReadyz();
|
|
463
|
+
expect(res.status).toBe(503);
|
|
464
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
465
|
+
expect(body.ready).toBe(false);
|
|
466
|
+
expect(body.notReady).toEqual(["db"]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("returns 200 when started and db ready even if CES is down", async () => {
|
|
470
|
+
resetReadinessForTest();
|
|
471
|
+
setStartupComplete();
|
|
472
|
+
setDbReady(true);
|
|
473
|
+
const explodingClient = {
|
|
474
|
+
isReady: () => {
|
|
475
|
+
throw new Error("/readyz must not consult CES");
|
|
476
|
+
},
|
|
477
|
+
close: () => {},
|
|
478
|
+
} as unknown as import("../credential-execution/client.js").CesClient;
|
|
479
|
+
setCesClient(explodingClient);
|
|
480
|
+
|
|
481
|
+
const res = handleReadyz();
|
|
482
|
+
expect(res.status).toBe(200);
|
|
483
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
484
|
+
expect(body.ready).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("returns 200 with the ok body when fully ready", async () => {
|
|
488
|
+
resetReadinessForTest();
|
|
489
|
+
setStartupComplete();
|
|
490
|
+
setDbReady(true);
|
|
491
|
+
const res = handleReadyz();
|
|
492
|
+
expect(res.status).toBe(200);
|
|
493
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
494
|
+
expect(body).toEqual({ status: "ok", ready: true, notReady: [] });
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
410
498
|
describe("identity routes — createdAt selection", () => {
|
|
411
499
|
test("falls back to mtime when birthtime is the Unix epoch", () => {
|
|
412
500
|
const mtime = new Date("2026-05-01T14:49:47.519Z");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isDbReady,
|
|
5
|
+
isStartupComplete,
|
|
6
|
+
resetReadinessForTest,
|
|
7
|
+
setDbReady,
|
|
8
|
+
setStartupComplete,
|
|
9
|
+
} from "../daemon-readiness.js";
|
|
10
|
+
|
|
11
|
+
describe("daemon-readiness", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
resetReadinessForTest();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
resetReadinessForTest();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("defaults are false", () => {
|
|
21
|
+
expect(isDbReady()).toBe(false);
|
|
22
|
+
expect(isStartupComplete()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("setDbReady flips state both ways", () => {
|
|
26
|
+
setDbReady(true);
|
|
27
|
+
expect(isDbReady()).toBe(true);
|
|
28
|
+
setDbReady(false);
|
|
29
|
+
expect(isDbReady()).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("setStartupComplete latches startup state", () => {
|
|
33
|
+
expect(isStartupComplete()).toBe(false);
|
|
34
|
+
setStartupComplete();
|
|
35
|
+
expect(isStartupComplete()).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("setStartupComplete is monotonic", () => {
|
|
39
|
+
setStartupComplete();
|
|
40
|
+
setStartupComplete();
|
|
41
|
+
expect(isStartupComplete()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("resetReadinessForTest clears both latches", () => {
|
|
45
|
+
setDbReady(true);
|
|
46
|
+
setStartupComplete();
|
|
47
|
+
resetReadinessForTest();
|
|
48
|
+
expect(isDbReady()).toBe(false);
|
|
49
|
+
expect(isStartupComplete()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Module-level readiness state for the daemon, readable synchronously from any
|
|
2
|
+
// request handler with no `await`. The `/readyz` probe gates on these latches
|
|
3
|
+
// to report whether the daemon can actually serve requests.
|
|
4
|
+
//
|
|
5
|
+
// CES is intentionally not latched here: it is read live
|
|
6
|
+
// (`getCesClient()?.isReady()`) only when reported in a response body, and must
|
|
7
|
+
// never gate readiness.
|
|
8
|
+
|
|
9
|
+
let dbReady = false;
|
|
10
|
+
let startupComplete = false;
|
|
11
|
+
|
|
12
|
+
export function setDbReady(v: boolean): void {
|
|
13
|
+
dbReady = v;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isDbReady(): boolean {
|
|
17
|
+
return dbReady;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One-way latch: once startup completes it stays complete for the process
|
|
21
|
+
// lifetime.
|
|
22
|
+
export function setStartupComplete(): void {
|
|
23
|
+
startupComplete = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isStartupComplete(): boolean {
|
|
27
|
+
return startupComplete;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resetReadinessForTest(): void {
|
|
31
|
+
dbReady = false;
|
|
32
|
+
startupComplete = false;
|
|
33
|
+
}
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -95,6 +95,7 @@ import { startAppSourceWatcher } from "./app-source-watcher.js";
|
|
|
95
95
|
import { startConfigWatcher } from "./config-watcher.js";
|
|
96
96
|
import { startConversationEvictor } from "./conversation-store.js";
|
|
97
97
|
import { writePid } from "./daemon-control.js";
|
|
98
|
+
import { setDbReady, setStartupComplete } from "./daemon-readiness.js";
|
|
98
99
|
import {
|
|
99
100
|
evaluateDiskPressureNow,
|
|
100
101
|
startDiskPressureGuard,
|
|
@@ -270,15 +271,35 @@ export async function runDaemon(): Promise<void> {
|
|
|
270
271
|
// depend on DB migrations having run (e.g. the inline-attachment-to-disk
|
|
271
272
|
// backfill that populates attachment filePaths).
|
|
272
273
|
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
274
|
+
// The daemon continues in a degraded state on either DB failure mode:
|
|
275
|
+
// (a) initializeDb() throws (e.g. the DB can't be opened), or (b) the DB
|
|
276
|
+
// opens but one or more migrations failed (initializeDb resolves with
|
|
277
|
+
// migrationsOk:false rather than throwing, per the daemon-never-blocks
|
|
278
|
+
// philosophy). In both cases DB-dependent features won't work, but the
|
|
279
|
+
// HTTP server and config-based subsystems still start so the process
|
|
280
|
+
// remains reachable for diagnostics. The trivial /healthz and detailed
|
|
281
|
+
// /v1/health(z) endpoints still answer 200, but setDbReady(true) runs only
|
|
282
|
+
// when migrations all applied, so the readiness latch stays unset and
|
|
283
|
+
// /readyz reports not-ready (503) for the rest of the process lifetime.
|
|
284
|
+
// That 503 is intentional: a DB-broken daemon should fail readiness so it
|
|
285
|
+
// is not routed user traffic.
|
|
286
|
+
//
|
|
287
|
+
// The local `dbReady` and the module-level readiness latch intentionally
|
|
288
|
+
// diverge on the migration-failure path: `dbReady` stays true to allow the
|
|
289
|
+
// downstream best-effort seeding / workspace migrations, while the latch
|
|
290
|
+
// stays unset to keep /readyz 503.
|
|
277
291
|
let dbReady = false;
|
|
278
292
|
try {
|
|
279
|
-
await initializeDb();
|
|
293
|
+
const { migrationsOk } = await initializeDb();
|
|
280
294
|
dbReady = true;
|
|
281
|
-
|
|
295
|
+
if (migrationsOk) {
|
|
296
|
+
setDbReady(true);
|
|
297
|
+
log.info("Daemon startup: DB initialized");
|
|
298
|
+
} else {
|
|
299
|
+
log.error(
|
|
300
|
+
"Daemon startup: DB opened but one or more migrations failed — /readyz will remain unready (degraded mode)",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
282
303
|
} catch (err) {
|
|
283
304
|
log.error(
|
|
284
305
|
{ err },
|
|
@@ -1166,6 +1187,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
1166
1187
|
|
|
1167
1188
|
installShutdownHandlers({ server });
|
|
1168
1189
|
|
|
1190
|
+
// The critical startup await-chain has completed and the daemon can serve
|
|
1191
|
+
// requests, so latch readiness before logging "Daemon started". Any fatal
|
|
1192
|
+
// failure earlier in startup propagates out of runDaemon before this line,
|
|
1193
|
+
// so the latch is never set on a failed start.
|
|
1194
|
+
setStartupComplete();
|
|
1195
|
+
|
|
1169
1196
|
log.info(
|
|
1170
1197
|
{
|
|
1171
1198
|
durationMs: Date.now() - startupStartedAt,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for initializeDb()'s readiness-gating return contract.
|
|
3
|
+
*
|
|
4
|
+
* The daemon's readiness latch (setDbReady) is gated on initializeDb resolving
|
|
5
|
+
* with migrationsOk:true, so a clean init must report all migrations applied.
|
|
6
|
+
* Migration-step failures are caught-and-logged inside the runner (not thrown),
|
|
7
|
+
* so the boolean is the only signal lifecycle has to keep /readyz unready on a
|
|
8
|
+
* partially-migrated schema — see assistant/src/daemon/lifecycle.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { initializeDb } from "../db-init.js";
|
|
14
|
+
|
|
15
|
+
describe("initializeDb — migrationsOk return contract", () => {
|
|
16
|
+
test("resolves to { migrationsOk: true } on a clean init", async () => {
|
|
17
|
+
const result = await initializeDb();
|
|
18
|
+
expect(result).toEqual({ migrationsOk: true });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -215,9 +215,10 @@ export async function checkpointWalBeforeOpen(): Promise<void> {
|
|
|
215
215
|
|
|
216
216
|
// ---------------------------------------------------------------------------
|
|
217
217
|
|
|
218
|
-
export async function initializeDb(): Promise<
|
|
218
|
+
export async function initializeDb(): Promise<{ migrationsOk: boolean }> {
|
|
219
219
|
if (process.env.BUN_TEST === "1" && tryRestoreTemplate()) {
|
|
220
|
-
|
|
220
|
+
// A restored template is fully migrated.
|
|
221
|
+
return { migrationsOk: true };
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
// Fold any post-crash WAL back into the database off the main event loop
|
|
@@ -260,13 +261,22 @@ export async function initializeDb(): Promise<void> {
|
|
|
260
261
|
);
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
// A passing post-run validation is part of readiness: validateMigrationState
|
|
265
|
+
// flags schema inconsistencies (e.g. a completed step missing a declared
|
|
266
|
+
// dependsOn checkpoint) that no individual step body surfaces as a failure.
|
|
267
|
+
let validationOk = true;
|
|
263
268
|
try {
|
|
264
269
|
validateMigrationState(database, migrationSteps);
|
|
265
270
|
} catch (err) {
|
|
271
|
+
validationOk = false;
|
|
266
272
|
log.error({ err }, "validateMigrationState failed");
|
|
267
273
|
}
|
|
268
274
|
|
|
269
275
|
if (process.env.BUN_TEST === "1") {
|
|
270
276
|
saveTemplate();
|
|
271
277
|
}
|
|
278
|
+
|
|
279
|
+
// migrationsOk reflects BOTH no failed migration steps AND a passing
|
|
280
|
+
// post-run validation, so an inconsistent schema keeps /readyz at 503.
|
|
281
|
+
return { migrationsOk: failed.length === 0 && validationOk };
|
|
272
282
|
}
|
|
@@ -8,6 +8,7 @@ import { availableParallelism, cpus, totalmem } from "node:os";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
|
|
10
10
|
import { getCpuLimit, getIsPlatform } from "../../config/env-registry.js";
|
|
11
|
+
import { isDbReady, isStartupComplete } from "../../daemon/daemon-readiness.js";
|
|
11
12
|
import { parseIdentityFields } from "../../daemon/handlers/identity.js";
|
|
12
13
|
import { getProfilerRuntimeStatus } from "../../daemon/profiler-run-store.js";
|
|
13
14
|
import { getMaxRollbackVersion } from "../../persistence/migrations/run-migrations.js";
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
getDiskUsageInfo,
|
|
18
19
|
parseK8sMemoryBytes,
|
|
19
20
|
} from "../../util/disk-usage.js";
|
|
20
|
-
import { getLogger } from "../../util/logger.js";
|
|
21
21
|
import { getWorkspacePromptPath } from "../../util/platform.js";
|
|
22
22
|
import { APP_VERSION } from "../../version.js";
|
|
23
23
|
import { resolveHatchedAtReadOnly } from "../../workspace/hatched-date.js";
|
|
@@ -314,8 +314,16 @@ function getCpuInfo(): CpuInfo {
|
|
|
314
314
|
};
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Trivial liveness/startup probe (`GET /healthz`).
|
|
319
|
+
*
|
|
320
|
+
* This is the k8s startup + liveness probe target: it must answer the instant
|
|
321
|
+
* the HTTP server is up and must NEVER touch DB, CES, migrations, or any other
|
|
322
|
+
* lifecycle state. Keep it to a static `{ status, version }` payload — no
|
|
323
|
+
* syscalls, no disk/memory/cpu reads, no async work.
|
|
324
|
+
*/
|
|
317
325
|
export function handleHealth(): Response {
|
|
318
|
-
return Response.json({ status: "ok" });
|
|
326
|
+
return Response.json({ status: "ok", version: APP_VERSION });
|
|
319
327
|
}
|
|
320
328
|
|
|
321
329
|
function getDetailedHealth() {
|
|
@@ -354,17 +362,28 @@ export function handleDetailedHealth(): Response {
|
|
|
354
362
|
return Response.json(getDetailedHealth());
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Strict readiness probe (`GET /readyz`).
|
|
367
|
+
*
|
|
368
|
+
* Reports whether the daemon can actually serve requests, gating on two
|
|
369
|
+
* monotonic startup latches read SYNCHRONOUSLY (no `await`, DB query, or
|
|
370
|
+
* subprocess) so the probe stays cheap on a ~10s interval. Returns 503 until
|
|
371
|
+
* both latches are set, then a stable 200 for the rest of the process lifetime.
|
|
372
|
+
*
|
|
373
|
+
* CES is intentionally never consulted: it is informational only. Gating on it
|
|
374
|
+
* would leave a pod permanently NotReady whenever CES never handshakes, even
|
|
375
|
+
* though the daemon serves degraded with an encrypted-store fallback.
|
|
376
|
+
*/
|
|
357
377
|
export function handleReadyz(): Response {
|
|
358
|
-
const
|
|
359
|
-
if (!
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return Response.json({ status: "ok" });
|
|
378
|
+
const notReady: string[] = [];
|
|
379
|
+
if (!isStartupComplete()) notReady.push("startup");
|
|
380
|
+
if (!isDbReady()) notReady.push("db");
|
|
381
|
+
const ready = notReady.length === 0;
|
|
382
|
+
|
|
383
|
+
return Response.json(
|
|
384
|
+
{ status: ready ? "ok" : "unready", ready, notReady },
|
|
385
|
+
{ status: ready ? 200 : 503 },
|
|
386
|
+
);
|
|
368
387
|
}
|
|
369
388
|
|
|
370
389
|
function getIdentity() {
|