@inflexa-ai/harness 0.24.0 → 0.26.0
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/dist/agents/sandbox/__fixtures__/deps.js +6 -0
- package/dist/agents/sandbox/data-profiler.js +5 -4
- package/dist/agents/sandbox/shared.js +11 -0
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/app/data-profile-orientation.js +30 -1
- package/dist/contracts/data-profile.d.ts +166 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/execution/input-signature.d.ts +33 -0
- package/dist/execution/input-signature.js +0 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +10 -3
- package/dist/input-scan/coverage.d.ts +22 -0
- package/dist/input-scan/coverage.js +85 -0
- package/dist/input-scan/enrich.d.ts +41 -0
- package/dist/input-scan/enrich.js +227 -0
- package/dist/input-scan/formats.d.ts +41 -0
- package/dist/input-scan/formats.js +279 -0
- package/dist/input-scan/index.d.ts +12 -0
- package/dist/input-scan/index.js +11 -0
- package/dist/input-scan/scan.d.ts +50 -0
- package/dist/input-scan/scan.js +182 -0
- package/dist/input-scan/shapes.d.ts +46 -0
- package/dist/input-scan/shapes.js +0 -0
- package/dist/input-scan/types.d.ts +172 -0
- package/dist/input-scan/types.js +12 -0
- package/dist/lib/page-capture.d.ts +44 -15
- package/dist/lib/page-capture.js +78 -19
- package/dist/loop/run-agent.js +36 -19
- package/dist/memory/card-builders.d.ts +4 -6
- package/dist/memory/card-builders.js +4 -6
- package/dist/memory/conversation-display-replay.d.ts +7 -6
- package/dist/memory/conversation-display-replay.js +7 -6
- package/dist/memory/conversation-display-storage.d.ts +2 -10
- package/dist/memory/conversation-display-storage.js +79 -32
- package/dist/memory/thread-history.d.ts +31 -20
- package/dist/memory/thread-history.js +45 -45
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +22 -13
- package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
- package/dist/prompts/sandbox/data-profiler.js +159 -275
- package/dist/provenance/exec-frame.js +9 -3
- package/dist/runtime/boot.d.ts +7 -8
- package/dist/runtime/boot.js +7 -19
- package/dist/sandbox/client.d.ts +7 -0
- package/dist/sandbox/create-sandbox.js +1 -0
- package/dist/sandbox/docker-client.d.ts +1 -0
- package/dist/sandbox/docker-client.js +20 -15
- package/dist/sandbox/k8s-client.d.ts +6 -5
- package/dist/sandbox/k8s-client.js +55 -39
- package/dist/sandbox/reaper.d.ts +24 -13
- package/dist/sandbox/reaper.js +47 -14
- package/dist/sandbox/types.d.ts +6 -5
- package/dist/schemas/data-profile-schemas.d.ts +62 -0
- package/dist/schemas/data-profile-schemas.js +68 -2
- package/dist/state/data-profile.d.ts +7 -88
- package/dist/state/index.d.ts +1 -1
- package/dist/tasks/data-profile-activity.d.ts +8 -0
- package/dist/tasks/data-profile-activity.js +1 -0
- package/dist/tasks/data-profile-index.d.ts +45 -0
- package/dist/tasks/data-profile-index.js +134 -0
- package/dist/tasks/data-profile.d.ts +8 -23
- package/dist/tasks/data-profile.js +150 -117
- package/dist/tools/define-tool.d.ts +19 -8
- package/dist/tools/define-tool.js +28 -12
- package/dist/tools/report-session/examine-page.d.ts +22 -13
- package/dist/tools/report-session/examine-page.js +15 -10
- package/dist/tools/report-session/index.d.ts +1 -1
- package/dist/tools/research/generate-plan.js +3 -8
- package/dist/tools/research/inspect-data-profile.d.ts +30 -4
- package/dist/tools/research/inspect-data-profile.js +75 -20
- package/dist/tools/workspace/scan-inputs.d.ts +38 -0
- package/dist/tools/workspace/scan-inputs.js +61 -0
- package/dist/tools/workspace/workspace-search.d.ts +1 -1
- package/dist/tools/workspace/workspace-search.js +12 -9
- package/dist/workspace/filesystem.d.ts +27 -0
- package/dist/workspace/filesystem.js +27 -0
- package/package.json +1 -1
- package/dist/app/data-profile-policy.d.ts +0 -36
- package/dist/app/data-profile-policy.js +0 -37
- package/dist/memory/content-to-cortex.d.ts +0 -75
- package/dist/memory/content-to-cortex.js +0 -201
- package/dist/memory/conversation-display-backfill.d.ts +0 -23
- package/dist/memory/conversation-display-backfill.js +0 -91
- package/dist/memory/reconstruct-cards.d.ts +0 -27
- package/dist/memory/reconstruct-cards.js +0 -57
|
@@ -348,6 +348,7 @@ export function createDockerSandboxOps(config) {
|
|
|
348
348
|
const labels = c.Labels ?? {};
|
|
349
349
|
return {
|
|
350
350
|
sandboxId: labels["cortex/sandbox-id"] ?? "",
|
|
351
|
+
// Docker label values are unconstrained, so the id is stored verbatim.
|
|
351
352
|
ownerWorkflowId: labels[OWNER_WORKFLOW_LABEL] ?? null,
|
|
352
353
|
// Docker reports `Created` as unix seconds.
|
|
353
354
|
createdAtMs: typeof c.Created === "number" ? c.Created * 1000 : null,
|
|
@@ -356,21 +357,25 @@ export function createDockerSandboxOps(config) {
|
|
|
356
357
|
.filter((s) => s.sandboxId.length > 0));
|
|
357
358
|
},
|
|
358
359
|
isAlive(ref) {
|
|
359
|
-
return
|
|
360
|
-
const info = await docker.getContainer(ref.sandboxId).inspect();
|
|
361
|
-
const oomKilled = info.State.OOMKilled === true;
|
|
362
|
-
// A running container is a reachable sandbox: the host dials its
|
|
363
|
-
// published loopback port directly.
|
|
364
|
-
return { alive: info.State.Running === true, oomKilled };
|
|
365
|
-
}, (status, cause) => ({
|
|
366
|
-
type: "liveness_failed",
|
|
367
|
-
op: "docker.isAlive",
|
|
368
|
-
sandboxId: ref.sandboxId,
|
|
369
|
-
status,
|
|
370
|
-
cause,
|
|
371
|
-
})).orElse((e) =>
|
|
372
|
-
// dockerode throws 404 for a missing container — observably dead.
|
|
373
|
-
statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
|
|
360
|
+
return isAliveById(ref.sandboxId);
|
|
374
361
|
},
|
|
362
|
+
isAliveById,
|
|
375
363
|
};
|
|
364
|
+
function isAliveById(sandboxId) {
|
|
365
|
+
return trySandbox(async () => {
|
|
366
|
+
const info = await docker.getContainer(sandboxId).inspect();
|
|
367
|
+
const oomKilled = info.State.OOMKilled === true;
|
|
368
|
+
// A running container is a reachable sandbox: the host dials its
|
|
369
|
+
// published loopback port directly.
|
|
370
|
+
return { alive: info.State.Running === true, oomKilled };
|
|
371
|
+
}, (status, cause) => ({
|
|
372
|
+
type: "liveness_failed",
|
|
373
|
+
op: "docker.isAlive",
|
|
374
|
+
sandboxId,
|
|
375
|
+
status,
|
|
376
|
+
cause,
|
|
377
|
+
})).orElse((e) =>
|
|
378
|
+
// dockerode throws 404 for a missing container — observably dead.
|
|
379
|
+
statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
|
|
380
|
+
}
|
|
376
381
|
}
|
|
@@ -20,11 +20,11 @@ import type { Logger } from "../lib/logger.js";
|
|
|
20
20
|
/**
|
|
21
21
|
* Coerce an arbitrary identifier into a valid K8s label value:
|
|
22
22
|
* ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* Without it one malformed value gets the whole Job rejected at admission.
|
|
24
|
+
*
|
|
25
|
+
* Lossy by construction: `:` becomes `-` and anything past 63 chars is dropped,
|
|
26
|
+
* neither of which is recoverable. No label value may therefore be used as a
|
|
27
|
+
* lookup key — see {@link OWNER_WORKFLOW_ANNOTATION}.
|
|
28
28
|
*/
|
|
29
29
|
export declare function sanitizeLabelValue(value: string): string;
|
|
30
30
|
export interface K8sClientConfig {
|
|
@@ -70,5 +70,6 @@ export declare function createK8sSandboxOps(config: K8sClientConfig): {
|
|
|
70
70
|
teardown(ref: SandboxRef): ResultAsync<void, SandboxError>;
|
|
71
71
|
teardownById(sandboxId: string): ResultAsync<void, SandboxError>;
|
|
72
72
|
isAlive(ref: SandboxRef): ResultAsync<SandboxLiveness, SandboxError>;
|
|
73
|
+
isAliveById(sandboxId: string): ResultAsync<SandboxLiveness, SandboxError>;
|
|
73
74
|
listManagedSandboxes(): ResultAsync<ManagedSandbox[], SandboxError>;
|
|
74
75
|
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* both sides address one directory by construction.
|
|
13
13
|
*/
|
|
14
14
|
import { relative as relativePath, sep } from "node:path";
|
|
15
|
-
import { BatchV1Api, CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
|
15
|
+
import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
|
|
16
16
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
17
17
|
import { trySandbox } from "./sandbox-error.js";
|
|
18
18
|
import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
|
|
@@ -29,18 +29,24 @@ const POD_READY_TIMEOUT_MS = 5 * 60_000;
|
|
|
29
29
|
const POD_POLL_INTERVAL_MS = 1_000;
|
|
30
30
|
const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
|
|
31
31
|
const MANAGED_BY_VALUE = "cortex";
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Annotation key of the owning DBOS workflow id. An annotation value has neither
|
|
34
|
+
* a length cap nor a charset restriction, so the id round-trips verbatim and can
|
|
35
|
+
* be handed back to `DBOS.getWorkflowStatus`. Deliberately not a label: nothing
|
|
36
|
+
* selects on it, and a label value could not hold it (see {@link sanitizeLabelValue}).
|
|
37
|
+
*/
|
|
38
|
+
const OWNER_WORKFLOW_ANNOTATION = "cortex/owner-workflow-id";
|
|
33
39
|
const RUN_ID_LABEL = "cortex/run-id";
|
|
34
40
|
const STEP_ID_LABEL = "cortex/step-id";
|
|
35
41
|
const ANALYSIS_ID_LABEL = "cortex/analysis-id";
|
|
36
42
|
/**
|
|
37
43
|
* Coerce an arbitrary identifier into a valid K8s label value:
|
|
38
44
|
* ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
45
|
+
* Without it one malformed value gets the whole Job rejected at admission.
|
|
46
|
+
*
|
|
47
|
+
* Lossy by construction: `:` becomes `-` and anything past 63 chars is dropped,
|
|
48
|
+
* neither of which is recoverable. No label value may therefore be used as a
|
|
49
|
+
* lookup key — see {@link OWNER_WORKFLOW_ANNOTATION}.
|
|
44
50
|
*/
|
|
45
51
|
export function sanitizeLabelValue(value) {
|
|
46
52
|
return value
|
|
@@ -49,6 +55,10 @@ export function sanitizeLabelValue(value) {
|
|
|
49
55
|
.slice(0, 63)
|
|
50
56
|
.replace(/[^A-Za-z0-9]+$/, "");
|
|
51
57
|
}
|
|
58
|
+
/** The owner workflow id recorded on a Job, or null if it records none. */
|
|
59
|
+
function readOwnerWorkflowId(metadata) {
|
|
60
|
+
return metadata?.annotations?.[OWNER_WORKFLOW_ANNOTATION] ?? null;
|
|
61
|
+
}
|
|
52
62
|
function deleteJobIgnoreMissing(batchApi, namespace, name) {
|
|
53
63
|
return trySandbox(async () => {
|
|
54
64
|
await batchApi.deleteNamespacedJob({
|
|
@@ -230,11 +240,13 @@ function buildJobSpec(meta, config, identity) {
|
|
|
230
240
|
metadata: {
|
|
231
241
|
name: sandboxId,
|
|
232
242
|
namespace: config.namespace,
|
|
243
|
+
annotations: {
|
|
244
|
+
[OWNER_WORKFLOW_ANNOTATION]: meta.childWorkflowId,
|
|
245
|
+
},
|
|
233
246
|
labels: {
|
|
234
247
|
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
|
235
248
|
role: "sandbox",
|
|
236
249
|
"cortex/sandbox-id": sandboxId,
|
|
237
|
-
[OWNER_WORKFLOW_LABEL]: sanitizeLabelValue(meta.childWorkflowId),
|
|
238
250
|
[STEP_ID_LABEL]: sanitizeLabelValue(meta.stepId),
|
|
239
251
|
...attributionLabels,
|
|
240
252
|
},
|
|
@@ -339,8 +351,8 @@ function waitForJobGone(batchApi, namespace, name) {
|
|
|
339
351
|
* dead prior attempt under the same checkpointed name: delete it, wait for the
|
|
340
352
|
* name to free, and recreate fresh.
|
|
341
353
|
*
|
|
342
|
-
* Adoption (and deletion) is **owner-guarded**: the existing Job's
|
|
343
|
-
*
|
|
354
|
+
* Adoption (and deletion) is **owner-guarded**: the existing Job's recorded
|
|
355
|
+
* owner workflow id must match this step's `ownerWorkflowId`.
|
|
344
356
|
* Only a recovery re-run carries the same checkpointed identity, so a mismatch
|
|
345
357
|
* means an (astronomically rare) name collision with a *different* step —
|
|
346
358
|
* adopting its pod would HMAC-fail every callback, and deleting its Job would
|
|
@@ -363,13 +375,13 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
|
|
|
363
375
|
const existingRead = await trySandbox(() => batchApi.readNamespacedJob({ namespace, name: sandboxId }), createFailed);
|
|
364
376
|
if (existingRead.isErr())
|
|
365
377
|
return err(existingRead.error);
|
|
366
|
-
const owner = existingRead.value.metadata
|
|
378
|
+
const owner = readOwnerWorkflowId(existingRead.value.metadata);
|
|
367
379
|
if (owner !== ownerWorkflowId) {
|
|
368
380
|
return err({
|
|
369
381
|
type: "name_conflict",
|
|
370
382
|
op: "k8s.createSandbox",
|
|
371
383
|
sandboxId,
|
|
372
|
-
owner
|
|
384
|
+
owner,
|
|
373
385
|
});
|
|
374
386
|
}
|
|
375
387
|
const terminal = await existingPodIsTerminal(coreApi, namespace, sandboxId);
|
|
@@ -396,7 +408,7 @@ export function createK8sSandboxOps(config) {
|
|
|
396
408
|
return new ResultAsync((async () => {
|
|
397
409
|
const { sandboxId } = identity;
|
|
398
410
|
const job = buildJobSpec(meta, config, identity);
|
|
399
|
-
const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId,
|
|
411
|
+
const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
|
|
400
412
|
if (adopted.isErr())
|
|
401
413
|
return err(adopted.error);
|
|
402
414
|
// A Job whose pod never schedules (quota rejection, no nodes, spot
|
|
@@ -452,40 +464,44 @@ export function createK8sSandboxOps(config) {
|
|
|
452
464
|
const ts = j.metadata?.creationTimestamp;
|
|
453
465
|
return {
|
|
454
466
|
sandboxId: labels["cortex/sandbox-id"] ?? j.metadata?.name ?? "",
|
|
455
|
-
ownerWorkflowId:
|
|
467
|
+
ownerWorkflowId: readOwnerWorkflowId(j.metadata),
|
|
456
468
|
createdAtMs: ts ? new Date(ts).getTime() : null,
|
|
457
469
|
};
|
|
458
470
|
})
|
|
459
471
|
.filter((s) => s.sandboxId.length > 0));
|
|
460
472
|
},
|
|
461
473
|
isAlive(ref) {
|
|
462
|
-
return
|
|
463
|
-
namespace: config.namespace,
|
|
464
|
-
labelSelector: `cortex/sandbox-id=${ref.sandboxId}`,
|
|
465
|
-
}), (status, cause) => ({
|
|
466
|
-
type: "liveness_failed",
|
|
467
|
-
op: "k8s.isAlive",
|
|
468
|
-
sandboxId: ref.sandboxId,
|
|
469
|
-
status,
|
|
470
|
-
cause,
|
|
471
|
-
}))
|
|
472
|
-
.map((pods) => {
|
|
473
|
-
const pod = pods.items[0];
|
|
474
|
-
if (!pod)
|
|
475
|
-
return { alive: false, oomKilled: false };
|
|
476
|
-
const phase = pod.status?.phase;
|
|
477
|
-
// `Pending` and `Running` are alive; `Succeeded`, `Failed`,
|
|
478
|
-
// `Unknown` are dead. `Pending` covers startup.
|
|
479
|
-
const alive = phase === "Pending" || phase === "Running";
|
|
480
|
-
const oomKilled = !alive &&
|
|
481
|
-
(pod.status?.containerStatuses ?? []).some((cs) => cs.state?.terminated?.reason === "OOMKilled" || cs.lastState?.terminated?.reason === "OOMKilled");
|
|
482
|
-
return { alive, oomKilled };
|
|
483
|
-
})
|
|
484
|
-
.orElse((e) =>
|
|
485
|
-
// A 404 means the pod is gone — observably dead.
|
|
486
|
-
statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
|
|
474
|
+
return isAliveById(ref.sandboxId);
|
|
487
475
|
},
|
|
476
|
+
isAliveById,
|
|
488
477
|
};
|
|
478
|
+
function isAliveById(sandboxId) {
|
|
479
|
+
return trySandbox(() => coreApi.listNamespacedPod({
|
|
480
|
+
namespace: config.namespace,
|
|
481
|
+
labelSelector: `cortex/sandbox-id=${sandboxId}`,
|
|
482
|
+
}), (status, cause) => ({
|
|
483
|
+
type: "liveness_failed",
|
|
484
|
+
op: "k8s.isAlive",
|
|
485
|
+
sandboxId,
|
|
486
|
+
status,
|
|
487
|
+
cause,
|
|
488
|
+
}))
|
|
489
|
+
.map((pods) => {
|
|
490
|
+
const pod = pods.items[0];
|
|
491
|
+
if (!pod)
|
|
492
|
+
return { alive: false, oomKilled: false };
|
|
493
|
+
const phase = pod.status?.phase;
|
|
494
|
+
// `Pending` and `Running` are alive; `Succeeded`, `Failed`,
|
|
495
|
+
// `Unknown` are dead. `Pending` covers startup.
|
|
496
|
+
const alive = phase === "Pending" || phase === "Running";
|
|
497
|
+
const oomKilled = !alive &&
|
|
498
|
+
(pod.status?.containerStatuses ?? []).some((cs) => cs.state?.terminated?.reason === "OOMKilled" || cs.lastState?.terminated?.reason === "OOMKilled");
|
|
499
|
+
return { alive, oomKilled };
|
|
500
|
+
})
|
|
501
|
+
.orElse((e) =>
|
|
502
|
+
// A 404 means the pod is gone — observably dead.
|
|
503
|
+
statusOf(e) === 404 ? ok({ alive: false, oomKilled: false }) : err(e));
|
|
504
|
+
}
|
|
489
505
|
}
|
|
490
506
|
/**
|
|
491
507
|
* Best-effort Job removal after a failed create. The original failure is what
|
package/dist/sandbox/reaper.d.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Distinct from the liveness watchdog (`sandbox/watchdog.ts`): the watchdog
|
|
6
6
|
* sweeps registry→cluster and unblocks stuck recvs; the reaper sweeps
|
|
7
7
|
* cluster→registry and garbage-collects. It lists every Cortex-managed sandbox
|
|
8
|
-
* machine in the configured namespace,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* machine in the configured namespace, resolves each machine's recorded owner
|
|
9
|
+
* workflow, and reaps the ones whose owner is terminal — tearing the machine
|
|
10
|
+
* down and reconciling its step row. A machine whose owner does not resolve at
|
|
11
|
+
* all is torn down only once a liveness probe says it is dead.
|
|
11
12
|
*
|
|
12
13
|
* Why a sweep and not teardown-on-cancel: DBOS forbids running a step in a
|
|
13
14
|
* cancelled workflow, so the body cannot tear down on the cancel path; and only
|
|
@@ -22,14 +23,18 @@ import type { Pool } from "pg";
|
|
|
22
23
|
import type { Logger } from "../lib/logger.js";
|
|
23
24
|
import type { SandboxClient } from "./client.js";
|
|
24
25
|
import type { ManagedSandbox } from "./types.js";
|
|
25
|
-
export type ReapDecision = "leave" | "reap";
|
|
26
|
+
export type ReapDecision = "leave" | "reap" | "reap-if-dead";
|
|
26
27
|
/**
|
|
27
|
-
* Decide whether a managed machine is reapable.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* Decide whether a managed machine is reapable. A resolved owner status is
|
|
29
|
+
* authoritative in both directions — DBOS records it at enqueue, *before* the
|
|
30
|
+
* machine exists, so an in-flight workflow's machine is always observably
|
|
31
|
+
* in-flight and a terminal one's is always reapable.
|
|
32
|
+
*
|
|
33
|
+
* A missing status is authoritative for neither. It means the machine records
|
|
34
|
+
* no owner, or the id it records did not resolve, or DBOS pruned the row —
|
|
35
|
+
* and age cannot separate those from a genuine orphan. So the decision defers
|
|
36
|
+
* to a liveness probe: only an observably dead machine is torn down. Reaping a
|
|
37
|
+
* live machine on age alone is what this branch exists to prevent.
|
|
33
38
|
*/
|
|
34
39
|
export declare function classifyManaged(sb: ManagedSandbox, status: {
|
|
35
40
|
status: string;
|
|
@@ -38,11 +43,11 @@ export declare function classifyManaged(sb: ManagedSandbox, status: {
|
|
|
38
43
|
export declare function terminalStepStatus(workflowStatus: string | null): "canceled" | "failed" | "completed";
|
|
39
44
|
export interface ReaperDeps {
|
|
40
45
|
pool: Pool;
|
|
41
|
-
sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById">;
|
|
46
|
+
sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById" | "isAliveById">;
|
|
42
47
|
getStatus: (workflowId: string) => Promise<{
|
|
43
48
|
status: string;
|
|
44
49
|
} | null>;
|
|
45
|
-
/** Creation-time grace
|
|
50
|
+
/** Creation-time grace before an unattributable machine is even probed. */
|
|
46
51
|
graceMs?: number;
|
|
47
52
|
/** Wall-clock ms; injected for tests. */
|
|
48
53
|
nowMs?: () => number;
|
|
@@ -53,6 +58,12 @@ export interface ReapSummary {
|
|
|
53
58
|
reapedCount: number;
|
|
54
59
|
rowsReconciled: number;
|
|
55
60
|
leftCount: number;
|
|
61
|
+
/**
|
|
62
|
+
* Machines past the grace whose owner did not resolve but which are still
|
|
63
|
+
* alive, so they were left standing. A non-zero value is a standing leak
|
|
64
|
+
* *and* a bug signal — it means owner ids are not round-tripping.
|
|
65
|
+
*/
|
|
66
|
+
liveUnattributed: number;
|
|
56
67
|
}
|
|
57
68
|
/**
|
|
58
69
|
* One cluster→registry sweep. Lists managed machines, classifies each against
|
|
@@ -62,7 +73,7 @@ export interface ReapSummary {
|
|
|
62
73
|
export declare function reapOnce(deps: ReaperDeps): Promise<ReapSummary>;
|
|
63
74
|
export interface RegisterReaperDeps {
|
|
64
75
|
pool: Pool;
|
|
65
|
-
sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById">;
|
|
76
|
+
sandboxClient: Pick<SandboxClient, "listManagedSandboxes" | "teardownById" | "isAliveById">;
|
|
66
77
|
graceMs?: number;
|
|
67
78
|
logger?: Logger;
|
|
68
79
|
}
|
package/dist/sandbox/reaper.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Distinct from the liveness watchdog (`sandbox/watchdog.ts`): the watchdog
|
|
6
6
|
* sweeps registry→cluster and unblocks stuck recvs; the reaper sweeps
|
|
7
7
|
* cluster→registry and garbage-collects. It lists every Cortex-managed sandbox
|
|
8
|
-
* machine in the configured namespace,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* machine in the configured namespace, resolves each machine's recorded owner
|
|
9
|
+
* workflow, and reaps the ones whose owner is terminal — tearing the machine
|
|
10
|
+
* down and reconciling its step row. A machine whose owner does not resolve at
|
|
11
|
+
* all is torn down only once a liveness probe says it is dead.
|
|
11
12
|
*
|
|
12
13
|
* Why a sweep and not teardown-on-cancel: DBOS forbids running a step in a
|
|
13
14
|
* cancelled workflow, so the body cannot tear down on the cancel path; and only
|
|
@@ -33,20 +34,22 @@ const DEFAULT_GRACE_MS = 10 * 60_000;
|
|
|
33
34
|
*/
|
|
34
35
|
const ACTIVE_WORKFLOW_STATUSES = new Set(["PENDING", "ENQUEUED", "RUNNING"]);
|
|
35
36
|
/**
|
|
36
|
-
* Decide whether a managed machine is reapable.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* Decide whether a managed machine is reapable. A resolved owner status is
|
|
38
|
+
* authoritative in both directions — DBOS records it at enqueue, *before* the
|
|
39
|
+
* machine exists, so an in-flight workflow's machine is always observably
|
|
40
|
+
* in-flight and a terminal one's is always reapable.
|
|
41
|
+
*
|
|
42
|
+
* A missing status is authoritative for neither. It means the machine records
|
|
43
|
+
* no owner, or the id it records did not resolve, or DBOS pruned the row —
|
|
44
|
+
* and age cannot separate those from a genuine orphan. So the decision defers
|
|
45
|
+
* to a liveness probe: only an observably dead machine is torn down. Reaping a
|
|
46
|
+
* live machine on age alone is what this branch exists to prevent.
|
|
42
47
|
*/
|
|
43
48
|
export function classifyManaged(sb, status, nowMs, graceMs) {
|
|
44
|
-
if (status && ACTIVE_WORKFLOW_STATUSES.has(status.status))
|
|
45
|
-
return "leave";
|
|
46
49
|
if (status)
|
|
47
|
-
return "reap";
|
|
50
|
+
return ACTIVE_WORKFLOW_STATUSES.has(status.status) ? "leave" : "reap";
|
|
48
51
|
const ageMs = sb.createdAtMs == null ? Number.POSITIVE_INFINITY : nowMs - sb.createdAtMs;
|
|
49
|
-
return ageMs >= graceMs ? "reap" : "leave";
|
|
52
|
+
return ageMs >= graceMs ? "reap-if-dead" : "leave";
|
|
50
53
|
}
|
|
51
54
|
/** Map the owning workflow's terminal status to the step row's terminal status. */
|
|
52
55
|
export function terminalStepStatus(workflowStatus) {
|
|
@@ -73,12 +76,41 @@ export async function reapOnce(deps) {
|
|
|
73
76
|
let reapedCount = 0;
|
|
74
77
|
let rowsReconciled = 0;
|
|
75
78
|
let leftCount = 0;
|
|
79
|
+
let liveUnattributed = 0;
|
|
76
80
|
for (const sb of managed) {
|
|
77
81
|
const status = sb.ownerWorkflowId ? await deps.getStatus(sb.ownerWorkflowId) : null;
|
|
78
|
-
|
|
82
|
+
const decision = classifyManaged(sb, status, nowMs, graceMs);
|
|
83
|
+
if (decision === "leave") {
|
|
79
84
|
leftCount++;
|
|
80
85
|
continue;
|
|
81
86
|
}
|
|
87
|
+
if (decision === "reap-if-dead") {
|
|
88
|
+
// A recorded id that resolves to nothing is an anomaly worth naming: either
|
|
89
|
+
// DBOS pruned the row or the id never reached the machine intact.
|
|
90
|
+
if (sb.ownerWorkflowId) {
|
|
91
|
+
logger.warn("owner workflow id resolved to no workflow", { sandboxId: sb.sandboxId, ownerWorkflowId: sb.ownerWorkflowId });
|
|
92
|
+
}
|
|
93
|
+
let liveness;
|
|
94
|
+
try {
|
|
95
|
+
liveness = await deps.sandboxClient.isAliveById(sb.sandboxId);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A probe that cannot answer is not a dead machine. Leave it; the next
|
|
99
|
+
// sweep re-probes.
|
|
100
|
+
logger.warn("liveness probe threw — leaving this machine", { sandboxId: sb.sandboxId, ...logger.errorFields(err) });
|
|
101
|
+
leftCount++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (liveness.alive) {
|
|
105
|
+
logger.warn("machine has no resolvable owner but is still alive — left standing", {
|
|
106
|
+
sandboxId: sb.sandboxId,
|
|
107
|
+
ownerWorkflowId: sb.ownerWorkflowId,
|
|
108
|
+
});
|
|
109
|
+
leftCount++;
|
|
110
|
+
liveUnattributed++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
82
114
|
try {
|
|
83
115
|
await deps.sandboxClient.teardownById(sb.sandboxId);
|
|
84
116
|
reapedCount++;
|
|
@@ -98,6 +130,7 @@ export async function reapOnce(deps) {
|
|
|
98
130
|
reapedCount,
|
|
99
131
|
rowsReconciled,
|
|
100
132
|
leftCount,
|
|
133
|
+
liveUnattributed,
|
|
101
134
|
};
|
|
102
135
|
// Spread: an interface has no implicit index signature, so it needs widening to `LogFields`.
|
|
103
136
|
logger.info("sweep completed", { ...summary });
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -229,8 +229,8 @@ export interface CreateSandboxMeta {
|
|
|
229
229
|
/** The first `execId` that will fire against this sandbox; nullable for
|
|
230
230
|
* early-create flows where the workflow mints the first execId later. */
|
|
231
231
|
execId?: string | null;
|
|
232
|
-
/** Owning DBOS child workflow id (`"${parentRunId}-${N}"`).
|
|
233
|
-
* sandbox machine
|
|
232
|
+
/** Owning DBOS child workflow id (`"${parentRunId}-${N}"`). Recorded verbatim
|
|
233
|
+
* on the sandbox machine under `cortex/owner-workflow-id` so the reaper can
|
|
234
234
|
* map a cluster-side machine back to its workflow and check liveness. */
|
|
235
235
|
childWorkflowId: string;
|
|
236
236
|
/** Backend-specific extras carried through to the per-backend impl. */
|
|
@@ -270,9 +270,10 @@ export interface SandboxIdentity {
|
|
|
270
270
|
/**
|
|
271
271
|
* A sandbox machine the backend is running that Cortex *manages*
|
|
272
272
|
* (`app.kubernetes.io/managed-by=cortex`), enumerated by the reaper for the
|
|
273
|
-
* cluster→registry sweep (see the harness-sandbox-exec spec).
|
|
274
|
-
*
|
|
275
|
-
*
|
|
273
|
+
* cluster→registry sweep (see the harness-sandbox-exec spec). `ownerWorkflowId`
|
|
274
|
+
* is the id as minted, so it is usable as a DBOS lookup key; null means the
|
|
275
|
+
* machine records no owner, and such a machine is reaped only past a
|
|
276
|
+
* creation-time grace and only when observably dead.
|
|
276
277
|
*/
|
|
277
278
|
export interface ManagedSandbox {
|
|
278
279
|
sandboxId: string;
|
|
@@ -47,6 +47,53 @@ export declare const OrganismSchema: z.ZodObject<{
|
|
|
47
47
|
notes: z.ZodOptional<z.ZodString>;
|
|
48
48
|
}, z.core.$strip>;
|
|
49
49
|
export type Organism = z.infer<typeof OrganismSchema>;
|
|
50
|
+
/** Kinds an output may carry. A tree needing more than this is not being grouped. */
|
|
51
|
+
export declare const MAX_KINDS = 30;
|
|
52
|
+
/** Axes an output may carry — an experimental design with more varying dimensions is vanishingly rare. */
|
|
53
|
+
export declare const MAX_AXES = 8;
|
|
54
|
+
/**
|
|
55
|
+
* Individually described files an output may carry. `files` holds NOTABLE SINGLETONS
|
|
56
|
+
* — the metadata sheet, the README, the paper, an outlier that fits no kind — not the
|
|
57
|
+
* dataset's contents.
|
|
58
|
+
*/
|
|
59
|
+
export declare const MAX_NOTABLE_FILES = 50;
|
|
60
|
+
/**
|
|
61
|
+
* A repeating set of files that are the same sort of thing.
|
|
62
|
+
*
|
|
63
|
+
* A kind is a claim about MEANING, so only the agent can author one: the input scan
|
|
64
|
+
* reports shapes (these filenames differ only here) and cannot establish that the
|
|
65
|
+
* files are the same sort of thing. Kinds need not correspond one-to-one with observed
|
|
66
|
+
* shapes — one kind may span several shapes, and one shape may split into several
|
|
67
|
+
* kinds.
|
|
68
|
+
*
|
|
69
|
+
* A singleton is a kind of `count` 1. There is one concept with a count, not two
|
|
70
|
+
* concepts with a threshold between them, so degenerate datasets need no special case.
|
|
71
|
+
*/
|
|
72
|
+
export declare const KindSchema: z.ZodObject<{
|
|
73
|
+
name: z.ZodString;
|
|
74
|
+
memberRepresents: z.ZodString;
|
|
75
|
+
description: z.ZodString;
|
|
76
|
+
count: z.ZodNumber;
|
|
77
|
+
pathPattern: z.ZodString;
|
|
78
|
+
format: z.ZodOptional<z.ZodString>;
|
|
79
|
+
axisLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
80
|
+
}, z.core.$strip>;
|
|
81
|
+
export type ProfilerKind = z.infer<typeof KindSchema>;
|
|
82
|
+
/**
|
|
83
|
+
* What varies across a kind's members.
|
|
84
|
+
*
|
|
85
|
+
* The scan reports that a filename position varies and which values it takes; what the
|
|
86
|
+
* variation IS — a subject, a timepoint, a treatment arm, a chromosome shard — is not
|
|
87
|
+
* derivable from the values, so the label is required and agent-supplied. An axis
|
|
88
|
+
* evident from a metadata sheet but not from filenames belongs here too.
|
|
89
|
+
*/
|
|
90
|
+
export declare const AxisSchema: z.ZodObject<{
|
|
91
|
+
label: z.ZodString;
|
|
92
|
+
cardinality: z.ZodNumber;
|
|
93
|
+
exampleValues: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
94
|
+
description: z.ZodOptional<z.ZodString>;
|
|
95
|
+
}, z.core.$strip>;
|
|
96
|
+
export type ProfilerAxis = z.infer<typeof AxisSchema>;
|
|
50
97
|
export declare const ProfilerOutputSchema: z.ZodObject<{
|
|
51
98
|
files: z.ZodArray<z.ZodObject<{
|
|
52
99
|
path: z.ZodString;
|
|
@@ -59,6 +106,21 @@ export declare const ProfilerOutputSchema: z.ZodObject<{
|
|
|
59
106
|
warnings: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
60
107
|
metrics: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
61
108
|
}, z.core.$strip>>;
|
|
109
|
+
kinds: z.ZodArray<z.ZodObject<{
|
|
110
|
+
name: z.ZodString;
|
|
111
|
+
memberRepresents: z.ZodString;
|
|
112
|
+
description: z.ZodString;
|
|
113
|
+
count: z.ZodNumber;
|
|
114
|
+
pathPattern: z.ZodString;
|
|
115
|
+
format: z.ZodOptional<z.ZodString>;
|
|
116
|
+
axisLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
117
|
+
}, z.core.$strip>>;
|
|
118
|
+
axes: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
119
|
+
label: z.ZodString;
|
|
120
|
+
cardinality: z.ZodNumber;
|
|
121
|
+
exampleValues: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
122
|
+
description: z.ZodOptional<z.ZodString>;
|
|
123
|
+
}, z.core.$strip>>>;
|
|
62
124
|
analysisSummary: z.ZodString;
|
|
63
125
|
domain: z.ZodString;
|
|
64
126
|
subtype: z.ZodOptional<z.ZodString>;
|
|
@@ -24,7 +24,7 @@ export const ProfilerFileSchema = z.object({
|
|
|
24
24
|
metrics: z
|
|
25
25
|
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
26
26
|
.optional()
|
|
27
|
-
.describe("
|
|
27
|
+
.describe("Identity-establishing facts as flat key-value pairs (e.g. sparsity, delimiter, normalizationState, missingRate)"),
|
|
28
28
|
});
|
|
29
29
|
/**
|
|
30
30
|
* Where an extracted subject fact came from. Used so downstream agents can
|
|
@@ -48,8 +48,74 @@ export const OrganismSchema = z.object({
|
|
|
48
48
|
"low: inferred from gene IDs or other indirect signals, OR sources disagree (in which case also record the conflict in qualityAssessment.concerns)."),
|
|
49
49
|
notes: z.string().optional().describe("Brief note when source is 'inferred' or confidence is 'low' (one sentence)."),
|
|
50
50
|
});
|
|
51
|
+
/** Kinds an output may carry. A tree needing more than this is not being grouped. */
|
|
52
|
+
export const MAX_KINDS = 30;
|
|
53
|
+
/** Axes an output may carry — an experimental design with more varying dimensions is vanishingly rare. */
|
|
54
|
+
export const MAX_AXES = 8;
|
|
55
|
+
/**
|
|
56
|
+
* Individually described files an output may carry. `files` holds NOTABLE SINGLETONS
|
|
57
|
+
* — the metadata sheet, the README, the paper, an outlier that fits no kind — not the
|
|
58
|
+
* dataset's contents.
|
|
59
|
+
*/
|
|
60
|
+
export const MAX_NOTABLE_FILES = 50;
|
|
61
|
+
/**
|
|
62
|
+
* A repeating set of files that are the same sort of thing.
|
|
63
|
+
*
|
|
64
|
+
* A kind is a claim about MEANING, so only the agent can author one: the input scan
|
|
65
|
+
* reports shapes (these filenames differ only here) and cannot establish that the
|
|
66
|
+
* files are the same sort of thing. Kinds need not correspond one-to-one with observed
|
|
67
|
+
* shapes — one kind may span several shapes, and one shape may split into several
|
|
68
|
+
* kinds.
|
|
69
|
+
*
|
|
70
|
+
* A singleton is a kind of `count` 1. There is one concept with a count, not two
|
|
71
|
+
* concepts with a threshold between them, so degenerate datasets need no special case.
|
|
72
|
+
*/
|
|
73
|
+
export const KindSchema = z.object({
|
|
74
|
+
name: z.string().describe("Short label for this set, e.g. 'per-patient variant calls', 'reference transcriptome'."),
|
|
75
|
+
memberRepresents: z
|
|
76
|
+
.string()
|
|
77
|
+
.describe("What ONE member of this set represents — 'one patient's somatic variant calls', 'one sequencing lane of one sample'. " +
|
|
78
|
+
"This is your grouping decision stated outright, and it is NOT the description: it cannot be answered by " +
|
|
79
|
+
"restating the shape the scan observed."),
|
|
80
|
+
description: z.string().describe("What this set contains and the role it plays in the analysis."),
|
|
81
|
+
count: z.number().int().describe("How many files are in this set."),
|
|
82
|
+
pathPattern: z
|
|
83
|
+
.string()
|
|
84
|
+
.describe("Glob matching this set's members, relative to the analysis root (e.g. 'data/inputs/vcf/*.vcf.gz'). " +
|
|
85
|
+
"Coverage is computed by matching this against the scanned tree, so a pattern that matches nothing reads as an uncovered kind."),
|
|
86
|
+
format: z.string().optional().describe("File format shared by the members (VCF, h5ad, CSV, …)."),
|
|
87
|
+
axisLabels: z
|
|
88
|
+
.array(z.string())
|
|
89
|
+
.max(MAX_AXES)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Labels of the axes (see `axes`) that vary across this set's members — the design this kind participates in."),
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* What varies across a kind's members.
|
|
95
|
+
*
|
|
96
|
+
* The scan reports that a filename position varies and which values it takes; what the
|
|
97
|
+
* variation IS — a subject, a timepoint, a treatment arm, a chromosome shard — is not
|
|
98
|
+
* derivable from the values, so the label is required and agent-supplied. An axis
|
|
99
|
+
* evident from a metadata sheet but not from filenames belongs here too.
|
|
100
|
+
*/
|
|
101
|
+
export const AxisSchema = z.object({
|
|
102
|
+
label: z.string().describe("What this dimension is: 'patient', 'timepoint', 'treatment arm', 'chromosome shard', 'replicate'."),
|
|
103
|
+
cardinality: z.number().int().describe("How many distinct values this dimension takes."),
|
|
104
|
+
exampleValues: z.array(z.string()).max(20).optional().describe("A few observed values, for orientation."),
|
|
105
|
+
description: z.string().optional().describe("Anything a downstream planner needs about this dimension (ordering, pairing, imbalance)."),
|
|
106
|
+
});
|
|
51
107
|
export const ProfilerOutputSchema = z.object({
|
|
52
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Notable singletons only. The workspace filesystem is the authoritative file list
|
|
110
|
+
* — `list_files`, `grep`, and the vector index all read the live tree — so a record
|
|
111
|
+
* per input file would store a stale duplicate of something that cannot go wrong.
|
|
112
|
+
*/
|
|
113
|
+
files: z.array(ProfilerFileSchema).max(MAX_NOTABLE_FILES),
|
|
114
|
+
kinds: z
|
|
115
|
+
.array(KindSchema)
|
|
116
|
+
.max(MAX_KINDS)
|
|
117
|
+
.describe("The repeating sets this dataset is made of. Group; do not enumerate members — the cap is a hard bound, not a target."),
|
|
118
|
+
axes: z.array(AxisSchema).max(MAX_AXES).optional().describe("What varies across the kinds' members — the experimental design, as far as it is observable."),
|
|
53
119
|
analysisSummary: z.string().describe("Narrative overview of the dataset — structure, content, analytical potential, and limitations"),
|
|
54
120
|
domain: z
|
|
55
121
|
.string()
|