@inflexa-ai/harness 0.37.0 → 0.39.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.
Files changed (101) hide show
  1. package/README.md +1 -2
  2. package/dist/auth/local-run-authorizer.js +5 -4
  3. package/dist/auth/types.d.ts +28 -9
  4. package/dist/auth/types.js +5 -3
  5. package/dist/billing/cached-resolver.d.ts +11 -7
  6. package/dist/billing/cached-resolver.js +17 -11
  7. package/dist/billing/noop-run-charge.js +3 -2
  8. package/dist/billing/noop-usage-recorder.js +2 -1
  9. package/dist/billing/run-charge.d.ts +18 -5
  10. package/dist/billing/run-charge.js +5 -1
  11. package/dist/billing/usage-recorder.d.ts +11 -9
  12. package/dist/billing/usage-recorder.js +2 -3
  13. package/dist/contracts/chat-events.d.ts +4 -1
  14. package/dist/contracts/chat-parts.d.ts +1 -1
  15. package/dist/contracts/schemas/chat-parts.d.ts +4 -4
  16. package/dist/execution/artifact-registration.d.ts +6 -1
  17. package/dist/execution/artifact-registration.js +12 -4
  18. package/dist/execution/artifact-registry.d.ts +10 -3
  19. package/dist/execution/noop-artifact-registry.js +3 -4
  20. package/dist/execution/post-step-pipeline.d.ts +14 -3
  21. package/dist/execution/post-step-pipeline.js +21 -8
  22. package/dist/execution/run-authorizer.d.ts +5 -3
  23. package/dist/execution/run-canceler.js +4 -20
  24. package/dist/index.d.ts +14 -8
  25. package/dist/index.js +12 -6
  26. package/dist/lib/hooks.d.ts +49 -0
  27. package/dist/lib/hooks.js +39 -0
  28. package/dist/lib/result.d.ts +22 -11
  29. package/dist/lib/result.js +24 -14
  30. package/dist/loop/run-agent.d.ts +2 -2
  31. package/dist/loop/run-agent.js +5 -4
  32. package/dist/provenance/seam.d.ts +1 -1
  33. package/dist/providers/ai-sdk.d.ts +43 -5
  34. package/dist/providers/ai-sdk.js +55 -52
  35. package/dist/providers/anthropic.d.ts +2 -3
  36. package/dist/providers/anthropic.js +2 -2
  37. package/dist/providers/embedding.d.ts +13 -7
  38. package/dist/providers/embedding.js +26 -6
  39. package/dist/providers/errors.d.ts +67 -37
  40. package/dist/providers/errors.js +57 -58
  41. package/dist/providers/request-headers.d.ts +18 -0
  42. package/dist/providers/request-headers.js +22 -0
  43. package/dist/report-model/production-resolver.d.ts +5 -2
  44. package/dist/report-model/production-resolver.js +12 -2
  45. package/dist/runtime/assemble.d.ts +3 -2
  46. package/dist/runtime/dbos.d.ts +4 -3
  47. package/dist/runtime/dbos.js +18 -3
  48. package/dist/runtime/result-serialization.d.ts +29 -0
  49. package/dist/runtime/result-serialization.js +45 -0
  50. package/dist/sandbox/client.d.ts +11 -3
  51. package/dist/sandbox/create-sandbox.d.ts +5 -2
  52. package/dist/sandbox/create-sandbox.js +51 -25
  53. package/dist/sandbox/docker-client.d.ts +4 -3
  54. package/dist/sandbox/docker-client.js +23 -31
  55. package/dist/sandbox/k8s-client.d.ts +4 -13
  56. package/dist/sandbox/k8s-client.js +28 -61
  57. package/dist/sandbox/labels.d.ts +34 -0
  58. package/dist/sandbox/labels.js +47 -0
  59. package/dist/sandbox/mount-plan.d.ts +3 -1
  60. package/dist/sandbox/mount-plan.js +9 -0
  61. package/dist/sandbox/sandbox-error.d.ts +36 -13
  62. package/dist/sandbox/sandbox-error.js +32 -17
  63. package/dist/sandbox/types.d.ts +18 -10
  64. package/dist/schemas/validate-plan.js +13 -0
  65. package/dist/schemas/workflow-state.d.ts +4 -4
  66. package/dist/state/analyses.d.ts +4 -4
  67. package/dist/state/analyses.js +4 -4
  68. package/dist/state/schema.d.ts +5 -5
  69. package/dist/tasks/data-profile.d.ts +5 -8
  70. package/dist/tasks/data-profile.js +88 -43
  71. package/dist/tasks/derive-table-exec.d.ts +10 -6
  72. package/dist/tasks/derive-table-exec.js +17 -12
  73. package/dist/tasks/extract-values.d.ts +16 -10
  74. package/dist/tasks/extract-values.js +34 -18
  75. package/dist/tools/execute-analysis.js +29 -24
  76. package/dist/tools/report-session/derive-table.d.ts +9 -1
  77. package/dist/tools/report-session/derive-table.js +25 -22
  78. package/dist/tools/report-session/examine-page.d.ts +6 -3
  79. package/dist/tools/report-session/examine-page.js +16 -8
  80. package/dist/tools/report-session/index.d.ts +1 -1
  81. package/dist/tools/report-session/preview-report.js +11 -9
  82. package/dist/tools/report-session/session-page-publisher.d.ts +17 -21
  83. package/dist/tools/report-session/session-page-publisher.js +5 -7
  84. package/dist/tools/research/inspect-run.d.ts +1 -1
  85. package/dist/tools/sandbox/link-packages.d.ts +8 -0
  86. package/dist/tools/sandbox/link-packages.js +19 -9
  87. package/dist/workflows/execute-analysis.d.ts +25 -16
  88. package/dist/workflows/execute-analysis.js +223 -216
  89. package/dist/workflows/metrics.d.ts +4 -3
  90. package/dist/workflows/metrics.js +4 -3
  91. package/dist/workflows/sandbox-step.d.ts +20 -29
  92. package/dist/workflows/sandbox-step.js +71 -127
  93. package/dist/workflows/suspension.d.ts +32 -0
  94. package/dist/workflows/suspension.js +38 -0
  95. package/package.json +1 -1
  96. package/dist/billing/noop-resolver.d.ts +0 -6
  97. package/dist/billing/noop-resolver.js +0 -7
  98. package/dist/billing/resolver.d.ts +0 -64
  99. package/dist/billing/resolver.js +0 -24
  100. package/dist/loop/budget-exceeded.d.ts +0 -38
  101. package/dist/loop/budget-exceeded.js +0 -88
@@ -40,10 +40,11 @@
40
40
  */
41
41
  import Docker from "dockerode";
42
42
  import { ResultAsync } from "neverthrow";
43
+ import type { SpawnSession } from "../auth/types.js";
43
44
  import type { Logger } from "../lib/logger.js";
44
45
  import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
45
46
  import { type SandboxError } from "./sandbox-error.js";
46
- import type { CreateSandboxMeta, FarmSource, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, ToolchainSource } from "./types.js";
47
+ import type { FarmSource, ManagedSandbox, SandboxIdentity, SandboxLabels, SandboxLiveness, SandboxRef, SandboxSpec, SandboxTransport, ToolchainSource } from "./types.js";
47
48
  export interface DockerClientConfig {
48
49
  image: string;
49
50
  /** Cortex base URL injected into the sandbox env in callback mode so callbacks land here. Unused in poll mode. */
@@ -112,7 +113,7 @@ export interface DockerClientConfig {
112
113
  */
113
114
  logger?: Logger;
114
115
  /** Hook called after the registry row is written. */
115
- registerSandbox: (meta: CreateSandboxMeta, ref: SandboxRef) => Promise<void>;
116
+ registerSandbox: (session: SpawnSession, spec: SandboxSpec, ref: SandboxRef) => Promise<void>;
116
117
  }
117
118
  /**
118
119
  * dockerode connection options for a configured engine socket, or `undefined`
@@ -123,7 +124,7 @@ export interface DockerClientConfig {
123
124
  */
124
125
  export declare function engineConnectionOptions(engineSocketPath: string | undefined): Docker.DockerOptions | undefined;
125
126
  export declare function createDockerSandboxOps(config: DockerClientConfig): {
126
- createSandbox(meta: CreateSandboxMeta, identity: SandboxIdentity): ResultAsync<SandboxRef, SandboxError>;
127
+ createSandbox(session: SpawnSession, spec: SandboxSpec, identity: SandboxIdentity, hostLabels: SandboxLabels): ResultAsync<SandboxRef, SandboxError>;
127
128
  teardown(ref: SandboxRef): ResultAsync<void, SandboxError>;
128
129
  teardownById(sandboxId: string): ResultAsync<void, SandboxError>;
129
130
  isAlive(ref: SandboxRef): ResultAsync<SandboxLiveness, SandboxError>;
@@ -47,7 +47,8 @@ import { createNoopLogger } from "../lib/console-logger.js";
47
47
  import { tailWritePrefix } from "../workspace/paths.js";
48
48
  import { trySandbox } from "./sandbox-error.js";
49
49
  import { readFarmLock, resolveFarmSource } from "./farm.js";
50
- import { buildMountPlan, sandboxWriteTail } from "./mount-plan.js";
50
+ import { harnessLabels, MANAGED_BY_LABEL, MANAGED_BY_VALUE, mergeLabels, OWNER_WORKFLOW_KEY, SANDBOX_ID_LABEL } from "./labels.js";
51
+ import { buildMountPlan, mountCoordsOf, sandboxWriteTail } from "./mount-plan.js";
51
52
  import { CPU_ONLINE_PATH, CPUINFO_PATH, cpuFiles, readHostCpuinfoOnce } from "./cpu-files.js";
52
53
  import { threadLimitEnv } from "./thread-env.js";
53
54
  /** Read the originating HTTP status off any `SandboxError` variant that carries one. */
@@ -69,11 +70,7 @@ const SANDBOX_USER = "1000:1000";
69
70
  */
70
71
  const SANDBOX_ROOT_USER = "0:0";
71
72
  const HEALTH_POLL_MS = 250;
72
- const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
73
- const MANAGED_BY_VALUE = "cortex";
74
- const OWNER_WORKFLOW_LABEL = "cortex/owner-workflow-id";
75
- const RUN_ID_LABEL = "cortex/run-id";
76
- const STEP_ID_LABEL = "cortex/step-id";
73
+ const OWNER_WORKFLOW_LABEL = OWNER_WORKFLOW_KEY;
77
74
  /** The directory next to the workspace root that holds the cpu files of each sandbox. */
78
75
  const CPU_FILES_DIR = ".cpu";
79
76
  /**
@@ -232,15 +229,17 @@ export function createDockerSandboxOps(config) {
232
229
  /** The cpu directory of each sandbox that this process wrote, for `teardown`. */
233
230
  const cpuDirs = new Map();
234
231
  return {
235
- createSandbox(meta, identity) {
232
+ createSandbox(session, spec, identity, hostLabels) {
236
233
  return new ResultAsync((async () => {
237
234
  const { sandboxId, callbackSecret } = identity;
235
+ const analysisId = session.scope.analysisId;
236
+ const coords = mountCoordsOf(session, spec);
238
237
  // Farm resolution comes before any container work: a resolver
239
238
  // refusal and a resolver throw refuse the whole call, and no
240
239
  // container is made. The reason of the embedder rides in the error.
241
240
  let farm;
242
241
  if (config.libStorePath) {
243
- const resolved = await resolveFarmSource(config.farmSource, meta.analysisId, "docker.createSandbox");
242
+ const resolved = await resolveFarmSource(config.farmSource, analysisId, "docker.createSandbox");
244
243
  if (resolved.isErr())
245
244
  return err(resolved.error);
246
245
  farm = resolved.value;
@@ -262,7 +261,7 @@ export function createDockerSandboxOps(config) {
262
261
  return err({
263
262
  type: "farm_unusable",
264
263
  op: "docker.createSandbox",
265
- analysisId: meta.analysisId,
264
+ analysisId,
266
265
  farmPath: farm.farmPath,
267
266
  lockPath: lock.error.lockPath,
268
267
  lockError: lock.error.type,
@@ -292,20 +291,20 @@ export function createDockerSandboxOps(config) {
292
291
  // not installed reference data yet — and warning on every create would be
293
292
  // noise. A store that appears mid-session is picked up by the next create.
294
293
  const refsMounted = !!config.refStorePath && refStoreUsable(config.refStorePath);
295
- const plan = buildMountPlan(meta, {
294
+ const plan = buildMountPlan(coords, {
296
295
  libs: libsMounted,
297
296
  refs: refsMounted,
298
297
  toolchainSource: config.toolchainSource,
299
298
  cache: cacheBindable,
300
299
  });
301
- const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
300
+ const hostTreePath = config.resolveWorkspaceRoot(analysisId);
302
301
  // `tailWritePrefix` (not a raw `join`) so the RW bind source runs through
303
302
  // the same validation as the pre-created write tree — a crafted stepId or
304
303
  // a crafted tail cannot escape the resolved root into the container. The
305
304
  // tail is the step directory, or the one the caller declared.
306
- const write = sandboxWriteTail(meta);
305
+ const write = sandboxWriteTail(coords);
307
306
  // The same floor as `threadLimitEnv`: a fractional cpu request is one cpu.
308
- const threads = Math.max(1, Math.floor(meta.resources.cpu));
307
+ const threads = Math.max(1, Math.floor(spec.resources.cpu));
309
308
  const cpuinfo = await readHostCpuinfo();
310
309
  if (cpuinfo === undefined) {
311
310
  logger.debug("no host /proc/cpuinfo — the sandbox gets the online file only", { sandboxId });
@@ -336,8 +335,8 @@ export function createDockerSandboxOps(config) {
336
335
  status,
337
336
  cause,
338
337
  });
339
- const image = meta.image ?? config.image;
340
- const spec = meta.resources;
338
+ const image = spec.image ?? config.image;
339
+ const limits = spec.resources;
341
340
  // Poll mode never dials out and carries no CORTEX_BASE_URL; it sets the
342
341
  // firewall flag so the root entrypoint installs the egress block before
343
342
  // dropping to uid 1000. Callback mode is the inverse.
@@ -349,9 +348,9 @@ export function createDockerSandboxOps(config) {
349
348
  SANDBOX_TRANSPORT: transport,
350
349
  SANDBOX_CALLBACK_SECRET: callbackSecret,
351
350
  ...(pollMode ? { SANDBOX_EGRESS_FIREWALL: "1" } : { CORTEX_BASE_URL: config.cortexBaseUrl }),
352
- ...threadLimitEnv(spec),
351
+ ...threadLimitEnv(limits),
353
352
  ...plan.env,
354
- ...(meta.extraEnv ?? {}),
353
+ ...(spec.extraEnv ?? {}),
355
354
  }).map(([k, v]) => `${k}=${v}`);
356
355
  const createOpts = {
357
356
  name: sandboxId,
@@ -360,14 +359,7 @@ export function createDockerSandboxOps(config) {
360
359
  Env: env,
361
360
  User: pollMode ? SANDBOX_ROOT_USER : SANDBOX_USER,
362
361
  WorkingDir: plan.workingDir,
363
- Labels: {
364
- [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
365
- [OWNER_WORKFLOW_LABEL]: meta.childWorkflowId,
366
- [RUN_ID_LABEL]: meta.runId,
367
- [STEP_ID_LABEL]: meta.stepId,
368
- role: "sandbox",
369
- "cortex/sandbox-id": sandboxId,
370
- },
362
+ Labels: mergeLabels(hostLabels, { ...harnessLabels(session, sandboxId), [OWNER_WORKFLOW_LABEL]: spec.childWorkflowId }),
371
363
  ExposedPorts: { [`${SANDBOX_SERVER_PORT}/tcp`]: {} },
372
364
  HostConfig: {
373
365
  Binds: binds,
@@ -385,17 +377,17 @@ export function createDockerSandboxOps(config) {
385
377
  // — and `no-new-privileges` prevents regaining any.
386
378
  ...(pollMode ? { CapAdd: ["NET_ADMIN", "SETUID", "SETGID", "SETPCAP"] } : {}),
387
379
  SecurityOpt: ["no-new-privileges"],
388
- NanoCpus: Math.round(spec.cpu * 1e9),
389
- Memory: spec.memoryGb * 1024 ** 3,
380
+ NanoCpus: Math.round(limits.cpu * 1e9),
381
+ Memory: limits.memoryGb * 1024 ** 3,
390
382
  // `Memory` alone lets the container swap as much again. A fork
391
383
  // storm then thrashes for minutes, and sandbox-server stops to
392
384
  // answer. With no swap the OOM killer removes the largest fork in
393
385
  // seconds, and sandbox-server survives. K8s runs with no swap.
394
- MemorySwap: spec.memoryGb * 1024 ** 3,
386
+ MemorySwap: limits.memoryGb * 1024 ** 3,
395
387
  AutoRemove: false,
396
388
  },
397
389
  };
398
- const sandbox = await createOrAdopt(docker, createOpts, sandboxId, meta.childWorkflowId, createFailed, logger);
390
+ const sandbox = await createOrAdopt(docker, createOpts, sandboxId, spec.childWorkflowId, createFailed, logger);
399
391
  if (sandbox.isErr())
400
392
  return err(sandbox.error);
401
393
  if (!sandbox.value.alreadyRunning) {
@@ -425,7 +417,7 @@ export function createDockerSandboxOps(config) {
425
417
  backend: "docker",
426
418
  callbackSecret,
427
419
  };
428
- const registered = await trySandbox(() => config.registerSandbox(meta, ref), createFailed);
420
+ const registered = await trySandbox(() => config.registerSandbox(session, spec, ref), createFailed);
429
421
  if (registered.isErr())
430
422
  return err(registered.error);
431
423
  return ok(ref);
@@ -450,7 +442,7 @@ export function createDockerSandboxOps(config) {
450
442
  .map((c) => {
451
443
  const labels = c.Labels ?? {};
452
444
  return {
453
- sandboxId: labels["cortex/sandbox-id"] ?? "",
445
+ sandboxId: labels[SANDBOX_ID_LABEL] ?? "",
454
446
  // Docker label values are unconstrained, so the id is stored verbatim.
455
447
  ownerWorkflowId: labels[OWNER_WORKFLOW_LABEL] ?? null,
456
448
  // Docker reports `Created` as unix seconds.
@@ -16,18 +16,9 @@ import { BatchV1Api, CoreV1Api, type V1Toleration } from "@kubernetes/client-nod
16
16
  import { ResultAsync } from "neverthrow";
17
17
  import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
18
18
  import { type SandboxError } from "./sandbox-error.js";
19
- import type { CreateSandboxMeta, FarmSource, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, ToolchainSource } from "./types.js";
19
+ import type { SpawnSession } from "../auth/types.js";
20
+ import type { FarmSource, ManagedSandbox, SandboxIdentity, SandboxLabels, SandboxLiveness, SandboxRef, SandboxSpec, SandboxTransport, ToolchainSource } from "./types.js";
20
21
  import type { Logger } from "../lib/logger.js";
21
- /**
22
- * Coerce an arbitrary identifier into a valid K8s label value:
23
- * ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
24
- * Without it one malformed value gets the whole Job rejected at admission.
25
- *
26
- * Lossy by construction: `:` becomes `-` and anything past 63 chars is dropped,
27
- * neither of which is recoverable. No label value may therefore be used as a
28
- * lookup key — see {@link OWNER_WORKFLOW_ANNOTATION}.
29
- */
30
- export declare function sanitizeLabelValue(value: string): string;
31
22
  export interface K8sClientConfig {
32
23
  /** Operational logging seam; omitted falls back to no-op. */
33
24
  readonly logger?: Logger;
@@ -93,7 +84,7 @@ export interface K8sClientConfig {
93
84
  /** Injected for tests. */
94
85
  batchApi?: BatchV1Api;
95
86
  coreApi?: CoreV1Api;
96
- registerSandbox: (meta: CreateSandboxMeta, ref: SandboxRef) => Promise<void>;
87
+ registerSandbox: (session: SpawnSession, spec: SandboxSpec, ref: SandboxRef) => Promise<void>;
97
88
  }
98
89
  /** Clock and sleep seams, so a test can drive the slow-pod warning without waiting a minute. */
99
90
  export interface WaitForPodReadyDeps {
@@ -106,7 +97,7 @@ export declare function waitForPodReady(coreApi: CoreV1Api, namespace: string, s
106
97
  podName: string;
107
98
  }, SandboxError>;
108
99
  export declare function createK8sSandboxOps(config: K8sClientConfig): {
109
- createSandbox(meta: CreateSandboxMeta, identity: SandboxIdentity): ResultAsync<SandboxRef, SandboxError>;
100
+ createSandbox(session: SpawnSession, spec: SandboxSpec, identity: SandboxIdentity, hostLabels: SandboxLabels): ResultAsync<SandboxRef, SandboxError>;
110
101
  teardown(ref: SandboxRef): ResultAsync<void, SandboxError>;
111
102
  teardownById(sandboxId: string): ResultAsync<void, SandboxError>;
112
103
  isAlive(ref: SandboxRef): ResultAsync<SandboxLiveness, SandboxError>;
@@ -17,7 +17,8 @@ import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
17
17
  import { ResultAsync, err, ok } from "neverthrow";
18
18
  import { trySandbox } from "./sandbox-error.js";
19
19
  import { readFarmLock, resolveFarmSource } from "./farm.js";
20
- import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
20
+ import { harnessLabels, MANAGED_BY_LABEL, MANAGED_BY_VALUE, mergeLabels, OWNER_WORKFLOW_KEY, SANDBOX_ID_LABEL } from "./labels.js";
21
+ import { buildMountPlan, buildSessionSubPaths, mountCoordsOf } from "./mount-plan.js";
21
22
  import { threadLimitEnv } from "./thread-env.js";
22
23
  import { createNoopLogger } from "../lib/console-logger.js";
23
24
  /** Read the originating HTTP status off any `SandboxError` variant that carries one. */
@@ -37,34 +38,14 @@ const POD_POLL_INTERVAL_MS = 1_000;
37
38
  * and the run pays that minute before any command executes.
38
39
  */
39
40
  const POD_READY_SLOW_MS = 60_000;
40
- const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
41
- const MANAGED_BY_VALUE = "cortex";
42
41
  /**
43
42
  * Annotation key of the owning DBOS workflow id. An annotation value has neither
44
43
  * a length cap nor a charset restriction, so the id round-trips verbatim and can
45
44
  * be handed back to `DBOS.getWorkflowStatus`. Deliberately not a label: nothing
46
- * selects on it, and a label value could not hold it (see {@link sanitizeLabelValue}).
45
+ * selects on it, and a DBOS workflow id can be longer than 63 characters and can
46
+ * hold `:`, thus it is not a valid label value.
47
47
  */
48
- const OWNER_WORKFLOW_ANNOTATION = "cortex/owner-workflow-id";
49
- const RUN_ID_LABEL = "cortex/run-id";
50
- const STEP_ID_LABEL = "cortex/step-id";
51
- const ANALYSIS_ID_LABEL = "cortex/analysis-id";
52
- /**
53
- * Coerce an arbitrary identifier into a valid K8s label value:
54
- * ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
55
- * Without it one malformed value gets the whole Job rejected at admission.
56
- *
57
- * Lossy by construction: `:` becomes `-` and anything past 63 chars is dropped,
58
- * neither of which is recoverable. No label value may therefore be used as a
59
- * lookup key — see {@link OWNER_WORKFLOW_ANNOTATION}.
60
- */
61
- export function sanitizeLabelValue(value) {
62
- return value
63
- .replace(/[^A-Za-z0-9._-]/g, "-")
64
- .replace(/^[^A-Za-z0-9]+/, "")
65
- .slice(0, 63)
66
- .replace(/[^A-Za-z0-9]+$/, "");
67
- }
48
+ const OWNER_WORKFLOW_ANNOTATION = OWNER_WORKFLOW_KEY;
68
49
  /** The owner workflow id recorded on a Job, or null if it records none. */
69
50
  function readOwnerWorkflowId(metadata) {
70
51
  return metadata?.annotations?.[OWNER_WORKFLOW_ANNOTATION] ?? null;
@@ -129,21 +110,22 @@ function pvcSubPath(value, what) {
129
110
  }
130
111
  return value;
131
112
  }
132
- function buildJobSpec(meta, config, identity, farm, libsMounted) {
113
+ function buildJobSpec(session, sandboxSpec, hostLabels, config, identity, farm, libsMounted) {
133
114
  const { sandboxId } = identity;
134
115
  // The lock gate of the package-store spec: `farmPath` is PVC-relative,
135
116
  // thus this host can prove the lock only through `libStorePvcRoot`. The
136
117
  // caller runs that proof and hands the verdict in `libsMounted`. Without
137
118
  // the root, the proof duty stays whole on the farm resolver — see
138
119
  // {@link ResolveAnalysisFarm}.
139
- const plan = buildMountPlan(meta, {
120
+ const coords = mountCoordsOf(session, sandboxSpec);
121
+ const plan = buildMountPlan(coords, {
140
122
  libs: libsMounted,
141
123
  refs: !!config.refStorePvc,
142
124
  toolchainSource: config.toolchainSource,
143
125
  cache: libsMounted && farm?.cachePath !== undefined,
144
126
  });
145
127
  const transport = config.transport ?? "poll";
146
- const spec = meta.resources;
128
+ const spec = sandboxSpec.resources;
147
129
  // Composed as one record, not as a list of `{name, value}`. A duplicate name
148
130
  // in the env of a container resolves at the kubelet, thus the later spread
149
131
  // must win here, not there.
@@ -156,7 +138,7 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
156
138
  SANDBOX_CALLBACK_SECRET: identity.callbackSecret,
157
139
  ...threadLimitEnv(spec),
158
140
  ...plan.env,
159
- ...(meta.extraEnv ?? {}),
141
+ ...(sandboxSpec.extraEnv ?? {}),
160
142
  }).map(([name, value]) => ({ name, value }));
161
143
  // requests == limits → Guaranteed QoS and a predictable OOM bound; also
162
144
  // satisfies the namespace ResourceQuota, which rejects any pod that omits
@@ -172,7 +154,7 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
172
154
  const volumes = [];
173
155
  const volumeMounts = [];
174
156
  if (config.sessionPvc) {
175
- const subPaths = buildSessionSubPaths(meta, workspaceSubPathFor(config, meta.analysisId));
157
+ const subPaths = buildSessionSubPaths(coords, workspaceSubPathFor(config, session.scope.analysisId));
176
158
  volumes.push({
177
159
  name: SESSION_VOLUME_NAME,
178
160
  persistentVolumeClaim: { claimName: config.sessionPvc },
@@ -244,7 +226,7 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
244
226
  containers: [
245
227
  {
246
228
  name: "sandbox",
247
- image: meta.image ?? config.image,
229
+ image: sandboxSpec.image ?? config.image,
248
230
  imagePullPolicy: "IfNotPresent",
249
231
  env,
250
232
  resources,
@@ -277,14 +259,8 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
277
259
  if (config.runtimeClassName)
278
260
  podSpec.runtimeClassName = config.runtimeClassName;
279
261
  // Pod-template placement is load-bearing: a cost reconciler allocates by pod
280
- // labels, thus a Job-only label is invisible to it. The host labels stay
281
- // opaque — every value passes through `sanitizeLabelValue`, because one
282
- // malformed value makes the API server reject the whole Job at admission.
283
- const attributionLabels = {
284
- [ANALYSIS_ID_LABEL]: sanitizeLabelValue(meta.analysisId),
285
- [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
286
- ...Object.fromEntries(Object.entries(meta.podLabels ?? {}).map(([key, value]) => [key, sanitizeLabelValue(value)])),
287
- };
262
+ // labels, thus a Job-only label is invisible to it.
263
+ const labels = mergeLabels(hostLabels, harnessLabels(session, sandboxId));
288
264
  return {
289
265
  apiVersion: "batch/v1",
290
266
  kind: "Job",
@@ -292,26 +268,16 @@ function buildJobSpec(meta, config, identity, farm, libsMounted) {
292
268
  name: sandboxId,
293
269
  namespace: config.namespace,
294
270
  annotations: {
295
- [OWNER_WORKFLOW_ANNOTATION]: meta.childWorkflowId,
296
- },
297
- labels: {
298
- [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
299
- role: "sandbox",
300
- "cortex/sandbox-id": sandboxId,
301
- [STEP_ID_LABEL]: sanitizeLabelValue(meta.stepId),
302
- ...attributionLabels,
271
+ [OWNER_WORKFLOW_ANNOTATION]: sandboxSpec.childWorkflowId,
303
272
  },
273
+ labels,
304
274
  },
305
275
  spec: {
306
276
  backoffLimit: 0,
307
277
  ttlSecondsAfterFinished: 60,
308
278
  template: {
309
279
  metadata: {
310
- labels: {
311
- role: "sandbox",
312
- "cortex/sandbox-id": sandboxId,
313
- ...attributionLabels,
314
- },
280
+ labels,
315
281
  },
316
282
  spec: podSpec,
317
283
  },
@@ -345,7 +311,7 @@ export function waitForPodReady(coreApi, namespace, sandboxId, deps = {}) {
345
311
  while (now() < deadline) {
346
312
  const listed = await trySandbox(() => coreApi.listNamespacedPod({
347
313
  namespace,
348
- labelSelector: `cortex/sandbox-id=${sandboxId}`,
314
+ labelSelector: `${SANDBOX_ID_LABEL}=${sandboxId}`,
349
315
  }), createFailed);
350
316
  if (listed.isErr())
351
317
  return err(listed.error);
@@ -386,7 +352,7 @@ export function waitForPodReady(coreApi, namespace, sandboxId, deps = {}) {
386
352
  function existingPodIsTerminal(coreApi, namespace, sandboxId) {
387
353
  return trySandbox(() => coreApi.listNamespacedPod({
388
354
  namespace,
389
- labelSelector: `cortex/sandbox-id=${sandboxId}`,
355
+ labelSelector: `${SANDBOX_ID_LABEL}=${sandboxId}`,
390
356
  }), (status, cause) => ({
391
357
  type: "container_create_failed",
392
358
  op: "k8s.existingPodIsTerminal",
@@ -484,15 +450,16 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
484
450
  export function createK8sSandboxOps(config) {
485
451
  const { batchApi, coreApi } = config.batchApi && config.coreApi ? { batchApi: config.batchApi, coreApi: config.coreApi } : buildKubeApi();
486
452
  return {
487
- createSandbox(meta, identity) {
453
+ createSandbox(session, spec, identity, hostLabels) {
488
454
  return new ResultAsync((async () => {
489
455
  const { sandboxId } = identity;
456
+ const analysisId = session.scope.analysisId;
490
457
  // Farm resolution comes before any cluster work: a resolver
491
458
  // refusal and a resolver throw refuse the whole call, and no
492
459
  // Job is made.
493
460
  let farm;
494
461
  if (config.libStorePvc) {
495
- const resolved = await resolveFarmSource(config.farmSource, meta.analysisId, "k8s.createSandbox");
462
+ const resolved = await resolveFarmSource(config.farmSource, analysisId, "k8s.createSandbox");
496
463
  if (resolved.isErr())
497
464
  return err(resolved.error);
498
465
  farm = resolved.value;
@@ -517,7 +484,7 @@ export function createK8sSandboxOps(config) {
517
484
  return err({
518
485
  type: "farm_unusable",
519
486
  op: "k8s.createSandbox",
520
- analysisId: meta.analysisId,
487
+ analysisId,
521
488
  farmPath: farm.farmPath,
522
489
  lockPath: lock.error.lockPath,
523
490
  lockError: lock.error.type,
@@ -530,8 +497,8 @@ export function createK8sSandboxOps(config) {
530
497
  .warn("the farm of the analysis has no usable inflexa.lock at sandbox creation — mounting no package store (sandbox degrades to available:false)", { libStorePvc: config.libStorePvc, farmPath: farm.farmPath, lockError: lock.error.type, sandboxId });
531
498
  }
532
499
  }
533
- const job = buildJobSpec(meta, config, identity, farm, libsMounted);
534
- const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
500
+ const job = buildJobSpec(session, spec, hostLabels, config, identity, farm, libsMounted);
501
+ const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, spec.childWorkflowId, job);
535
502
  if (adopted.isErr())
536
503
  return err(adopted.error);
537
504
  // A Job whose pod never schedules (quota rejection, no nodes, spot
@@ -549,7 +516,7 @@ export function createK8sSandboxOps(config) {
549
516
  backend: "k8s",
550
517
  callbackSecret: identity.callbackSecret,
551
518
  };
552
- const registered = await trySandbox(() => config.registerSandbox(meta, ref), (status, cause) => ({
519
+ const registered = await trySandbox(() => config.registerSandbox(session, spec, ref), (status, cause) => ({
553
520
  type: "container_create_failed",
554
521
  op: "k8s.registerSandbox",
555
522
  sandboxId,
@@ -586,7 +553,7 @@ export function createK8sSandboxOps(config) {
586
553
  const labels = j.metadata?.labels ?? {};
587
554
  const ts = j.metadata?.creationTimestamp;
588
555
  return {
589
- sandboxId: labels["cortex/sandbox-id"] ?? j.metadata?.name ?? "",
556
+ sandboxId: labels[SANDBOX_ID_LABEL] ?? j.metadata?.name ?? "",
590
557
  ownerWorkflowId: readOwnerWorkflowId(j.metadata),
591
558
  createdAtMs: ts ? new Date(ts).getTime() : null,
592
559
  };
@@ -601,7 +568,7 @@ export function createK8sSandboxOps(config) {
601
568
  function isAliveById(sandboxId) {
602
569
  return trySandbox(() => coreApi.listNamespacedPod({
603
570
  namespace: config.namespace,
604
- labelSelector: `cortex/sandbox-id=${sandboxId}`,
571
+ labelSelector: `${SANDBOX_ID_LABEL}=${sandboxId}`,
605
572
  }), (status, cause) => ({
606
573
  type: "liveness_failed",
607
574
  op: "k8s.isAlive",
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The labels of a sandbox, the same on the Docker backend and on the K8s
3
+ * backend (sandbox-labels spec).
4
+ *
5
+ * Each harness value is a valid K8s label value by construction. A run id is a
6
+ * UUID or a short literal of the harness, a step id obeys the step-id rule of
7
+ * the plan validator, and the harness makes the sandbox id. The analysis id
8
+ * comes from the host, thus on K8s the host must give one that is a valid
9
+ * label value.
10
+ *
11
+ * The host labels merge first and the harness labels last, thus a harness key
12
+ * wins a clash. The harness stamps each host value as the host gives it and
13
+ * changes no value: a host reconciler uses a label value as a lookup key, and
14
+ * a changed value cannot find its record. On K8s the API server does a check of
15
+ * each value at admission, and it is the authority.
16
+ */
17
+ import type { SpawnSession } from "../auth/types.js";
18
+ import type { SandboxLabels } from "./types.js";
19
+ export declare const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
20
+ export declare const MANAGED_BY_VALUE = "cortex";
21
+ export declare const SANDBOX_ID_LABEL = "cortex/sandbox-id";
22
+ export declare const ANALYSIS_ID_LABEL = "cortex/analysis-id";
23
+ export declare const RUN_ID_LABEL = "cortex/run-id";
24
+ export declare const STEP_ID_LABEL = "cortex/step-id";
25
+ /**
26
+ * A Docker label value has no limit, thus Docker keeps the owner workflow id as
27
+ * a label. A DBOS workflow id can be longer than 63 characters and can hold
28
+ * `:`, thus K8s keeps it as a Job annotation.
29
+ */
30
+ export declare const OWNER_WORKFLOW_KEY = "cortex/owner-workflow-id";
31
+ /** The harness never applies it to a host value. */
32
+ export declare function isLabelValue(value: string): boolean;
33
+ export declare function harnessLabels(session: SpawnSession, sandboxId: string): Record<string, string>;
34
+ export declare function mergeLabels(hostLabels: SandboxLabels, harness: Readonly<Record<string, string>>): Record<string, string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The labels of a sandbox, the same on the Docker backend and on the K8s
3
+ * backend (sandbox-labels spec).
4
+ *
5
+ * Each harness value is a valid K8s label value by construction. A run id is a
6
+ * UUID or a short literal of the harness, a step id obeys the step-id rule of
7
+ * the plan validator, and the harness makes the sandbox id. The analysis id
8
+ * comes from the host, thus on K8s the host must give one that is a valid
9
+ * label value.
10
+ *
11
+ * The host labels merge first and the harness labels last, thus a harness key
12
+ * wins a clash. The harness stamps each host value as the host gives it and
13
+ * changes no value: a host reconciler uses a label value as a lookup key, and
14
+ * a changed value cannot find its record. On K8s the API server does a check of
15
+ * each value at admission, and it is the authority.
16
+ */
17
+ export const MANAGED_BY_LABEL = "app.kubernetes.io/managed-by";
18
+ export const MANAGED_BY_VALUE = "cortex";
19
+ export const SANDBOX_ID_LABEL = "cortex/sandbox-id";
20
+ export const ANALYSIS_ID_LABEL = "cortex/analysis-id";
21
+ export const RUN_ID_LABEL = "cortex/run-id";
22
+ export const STEP_ID_LABEL = "cortex/step-id";
23
+ /**
24
+ * A Docker label value has no limit, thus Docker keeps the owner workflow id as
25
+ * a label. A DBOS workflow id can be longer than 63 characters and can hold
26
+ * `:`, thus K8s keeps it as a Job annotation.
27
+ */
28
+ export const OWNER_WORKFLOW_KEY = "cortex/owner-workflow-id";
29
+ /** A valid K8s label value: 1 to 63 characters, `[A-Za-z0-9._-]` only, a letter or a digit at each end. */
30
+ const LABEL_VALUE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/;
31
+ /** The harness never applies it to a host value. */
32
+ export function isLabelValue(value) {
33
+ return LABEL_VALUE.test(value);
34
+ }
35
+ export function harnessLabels(session, sandboxId) {
36
+ return {
37
+ [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
38
+ role: "sandbox",
39
+ [SANDBOX_ID_LABEL]: sandboxId,
40
+ [ANALYSIS_ID_LABEL]: session.scope.analysisId,
41
+ [RUN_ID_LABEL]: session.runFrame.runId,
42
+ [STEP_ID_LABEL]: session.runFrame.stepId,
43
+ };
44
+ }
45
+ export function mergeLabels(hostLabels, harness) {
46
+ return { ...hostLabels, ...harness };
47
+ }
@@ -27,7 +27,8 @@
27
27
  * resolved root directly, K8s addresses it as a `subPath` into the session PVC
28
28
  * (see {@link buildSessionSubPaths}).
29
29
  */
30
- import type { ToolchainSource } from "./types.js";
30
+ import type { SpawnSession } from "../auth/types.js";
31
+ import type { SandboxSpec, ToolchainSource } from "./types.js";
31
32
  export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "logs", "notebooks"];
32
33
  /**
33
34
  * Container mountpoints of the two read-only stores. Exported because they are
@@ -64,6 +65,7 @@ export interface MountPlanCoords {
64
65
  */
65
66
  writableTail?: string;
66
67
  }
68
+ export declare function mountCoordsOf(session: SpawnSession, spec: Pick<SandboxSpec, "readOnly" | "writableTail">): MountPlanCoords;
67
69
  export interface MountPlanStores {
68
70
  /** Lib store is mounted at `/mnt/libs`, with the farm nested inside it, and its env is emitted. */
69
71
  libs: boolean;
@@ -47,6 +47,15 @@ export const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
47
47
  export function farmContainerPath(toolchainSource) {
48
48
  return toolchainSource === "image" ? `${LIBS_CONTAINER_PATH}/farm` : `${LIBS_CONTAINER_PATH}/current`;
49
49
  }
50
+ export function mountCoordsOf(session, spec) {
51
+ return {
52
+ analysisId: session.scope.analysisId,
53
+ runId: session.runFrame.runId,
54
+ stepId: session.runFrame.stepId,
55
+ ...(spec.readOnly !== undefined ? { readOnly: spec.readOnly } : {}),
56
+ ...(spec.writableTail !== undefined ? { writableTail: spec.writableTail } : {}),
57
+ };
58
+ }
50
59
  /**
51
60
  * The step tail beneath an analysis's workspace root, in both container and
52
61
  * PVC-relative space. The harness owns this layout (workspace-layout spec).
@@ -25,21 +25,21 @@
25
25
  * propagate untouched. `await-exec.ts` is NOT converted; `trySandbox` only
26
26
  * ever wraps the single SDK/HTTP call handed to it.
27
27
  *
28
- * The public `SandboxClient` interface keeps `Promise<T>` method signatures.
29
- * `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
30
- * each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
31
- * seam throws the described failure with the variant on `.cause` and on
32
- * `.error`. A `catch` never sees the raw variant. (`submitExec` is the
33
- * exception — it is already its own DBOS step and throws directly, so the seam
34
- * forwards its `Promise<void>` as-is.)
35
- * Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
36
- * `DBOS.runStep`, so the re-thrown error records the step as failed;
37
- * non-workflow consumers (`reaper`, `watchdog`,
38
- * `data-profile`, `run-exec`) already handle a thrown failure. They are
39
- * untouched by this wave.
28
+ * `createSandbox` of the public `SandboxClient` gives each `SandboxError` as
29
+ * an `err` value, because a refusal of the label hook must reach the spawn path
30
+ * as a value. A spawn path splits the result with `keepSuspendingRefusal`: a
31
+ * refusal with the suspend flag stays a value, and each other failure throws
32
+ * as a `SandboxFailure`.
33
+ * Each other op keeps a `Promise<T>` signature. `create-sandbox.ts` is the
34
+ * composition seam: it builds the backend ops, maps each `err` of such an op to
35
+ * a `SandboxFailure`, and `unwrapOrThrow`s it, so the seam throws the described
36
+ * failure with the variant on `.cause` and on `.error`. A `catch` never sees
37
+ * the raw variant. (`submitExec` is the exception — it is already its own DBOS
38
+ * step and throws directly, so the seam forwards its `Promise<void>` as-is.)
40
39
  */
41
- import { ResultAsync } from "neverthrow";
40
+ import { ResultAsync, type Result } from "neverthrow";
42
41
  import type { FarmLockError } from "./farm.js";
42
+ import type { SandboxRef } from "./types.js";
43
43
  /**
44
44
  * A sandbox backend failure. Absence / idempotent "already gone" is NOT
45
45
  * modelled — those ride the OK channel (`ok(false)` for a dead/missing machine,
@@ -82,6 +82,18 @@ export type SandboxError = {
82
82
  readonly sandboxId?: string;
83
83
  readonly status?: number;
84
84
  readonly cause: unknown;
85
+ } | {
86
+ /**
87
+ * The label hook of the host refused the spawn. The client made no
88
+ * step tree and called no backend. `reason` is the text of the host,
89
+ * and `suspend` asks the caller to suspend the work in place of a
90
+ * failure (see `lib/hooks.ts`).
91
+ */
92
+ readonly type: "labels_refused";
93
+ readonly op: string;
94
+ readonly reason: string;
95
+ readonly suspend: boolean;
96
+ readonly cause?: unknown;
85
97
  } | {
86
98
  /**
87
99
  * The farm source refused the analysis: the resolver returned
@@ -137,6 +149,17 @@ export declare class SandboxFailure extends Error {
137
149
  readonly error: SandboxError;
138
150
  constructor(error: SandboxError);
139
151
  }
152
+ export type SuspendingRefusal = Extract<SandboxError, {
153
+ readonly type: "labels_refused";
154
+ }> & {
155
+ readonly suspend: true;
156
+ };
157
+ /**
158
+ * Split the result of a spawn at a DBOS boundary. Call it inside the step of
159
+ * the spawn, before the checkpoint: the cause of a backend failure does not
160
+ * survive the serialization of a `Result`.
161
+ */
162
+ export declare function keepSuspendingRefusal(result: Result<SandboxRef, SandboxError>): Result<SandboxRef, SuspendingRefusal>;
140
163
  /**
141
164
  * Wrap a single sandbox backend call (Docker SDK op, K8s API op, or the
142
165
  * `/exec` POST). A throw becomes `err(toError(status, cause))`; the caller