harnery 0.19.1 → 0.21.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 (57) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/supervisor.d.ts.map +1 -1
  3. package/dist/commands/supervisor.js +21 -1
  4. package/dist/core/supervisor/index.d.ts +1 -1
  5. package/dist/core/supervisor/index.d.ts.map +1 -1
  6. package/dist/core/supervisor/index.js +1 -1
  7. package/dist/core/supervisor/plan-api.d.ts +7 -0
  8. package/dist/core/supervisor/plan-api.d.ts.map +1 -1
  9. package/dist/core/supervisor/plan-api.js +17 -1
  10. package/dist/core/supervisor/plan-read.d.ts.map +1 -1
  11. package/dist/core/supervisor/plan-read.js +4 -0
  12. package/dist/core/supervisor/plan-types.d.ts +2 -2
  13. package/dist/core/supervisor/plan-types.d.ts.map +1 -1
  14. package/dist/core/supervisor/planning.d.ts +7 -0
  15. package/dist/core/supervisor/planning.d.ts.map +1 -1
  16. package/dist/core/supervisor/planning.js +38 -0
  17. package/dist/core/supervisor/state.d.ts +2 -1
  18. package/dist/core/supervisor/state.d.ts.map +1 -1
  19. package/dist/core/supervisor/state.js +4 -1
  20. package/dist/core/work/runner.d.ts +1 -1
  21. package/dist/core/work/runner.d.ts.map +1 -1
  22. package/dist/core/work/runner.js +8 -0
  23. package/dist/core/work/state.d.ts.map +1 -1
  24. package/dist/core/work/state.js +24 -4
  25. package/dist/core/workflow/engine.d.ts.map +1 -1
  26. package/dist/core/workflow/engine.js +29 -3
  27. package/dist/core/workflow/index.d.ts +2 -2
  28. package/dist/core/workflow/index.d.ts.map +1 -1
  29. package/dist/core/workflow/index.js +1 -1
  30. package/dist/core/workflow/proof.d.ts +2 -1
  31. package/dist/core/workflow/proof.d.ts.map +1 -1
  32. package/dist/core/workflow/proof.js +8 -1
  33. package/dist/core/workflow/run-state.d.ts +2 -1
  34. package/dist/core/workflow/run-state.d.ts.map +1 -1
  35. package/dist/core/workflow/run-state.js +5 -0
  36. package/dist/core/workflow/types.d.ts +16 -0
  37. package/dist/core/workflow/types.d.ts.map +1 -1
  38. package/dist/core/workflow/types.js +1 -0
  39. package/dist/core/workflow/work-context.d.ts +5 -0
  40. package/dist/core/workflow/work-context.d.ts.map +1 -0
  41. package/dist/core/workflow/work-context.js +58 -0
  42. package/package.json +1 -1
  43. package/src/commands/supervisor.ts +32 -0
  44. package/src/core/supervisor/index.ts +5 -1
  45. package/src/core/supervisor/plan-api.ts +27 -1
  46. package/src/core/supervisor/plan-read.ts +4 -0
  47. package/src/core/supervisor/plan-types.ts +2 -0
  48. package/src/core/supervisor/planning.ts +52 -0
  49. package/src/core/supervisor/state.ts +7 -1
  50. package/src/core/work/runner.ts +9 -1
  51. package/src/core/work/state.ts +35 -4
  52. package/src/core/workflow/engine.ts +33 -2
  53. package/src/core/workflow/index.ts +5 -1
  54. package/src/core/workflow/proof.ts +12 -1
  55. package/src/core/workflow/run-state.ts +7 -1
  56. package/src/core/workflow/types.ts +17 -0
  57. package/src/core/workflow/work-context.ts +66 -0
@@ -64,9 +64,11 @@ import type {
64
64
  WorkflowEvidenceRecord,
65
65
  WorkflowModule,
66
66
  WorkflowProof,
67
+ WorkflowWorkContext,
67
68
  } from "./types.ts";
68
69
  import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
69
70
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
71
+ import { freezeWorkflowWorkContext } from "./work-context.ts";
70
72
 
71
73
  const DEFAULT_MAX_AGENTS = 50;
72
74
  const DEFAULT_CONCURRENCY = 4;
@@ -110,6 +112,19 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
110
112
  if (opts.workItemId && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(opts.workItemId)) {
111
113
  throw new Error(`invalid work item id ${JSON.stringify(opts.workItemId)}`);
112
114
  }
115
+ if (opts.resumeRunId && opts.workContext !== undefined) {
116
+ throw new Error("parked workflow resume uses the frozen manifest work context");
117
+ }
118
+ const requestedWorkContext =
119
+ opts.workContext === undefined ? undefined : freezeWorkflowWorkContext(opts.workContext);
120
+ if (requestedWorkContext && !opts.workItemId) {
121
+ throw new Error("workflow work context requires a work item id");
122
+ }
123
+ if (requestedWorkContext && requestedWorkContext.id !== opts.workItemId) {
124
+ throw new Error(
125
+ `workflow work context ${requestedWorkContext.id} does not match work item ${opts.workItemId}`,
126
+ );
127
+ }
113
128
  if (opts.resumeRunId && opts.resumeFrom) {
114
129
  throw new Error("resumeRunId and resumeFrom are mutually exclusive");
115
130
  }
@@ -123,6 +138,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
123
138
  ) {
124
139
  throw new Error(`workflow run ${opts.resumeRunId} belongs to a different work item`);
125
140
  }
141
+ const workContext = resumeState?.manifest.work_context
142
+ ? freezeWorkflowWorkContext(resumeState.manifest.work_context)
143
+ : requestedWorkContext;
126
144
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
127
145
  if (resumeState) {
128
146
  if (resolve(resumeState.manifest.script.path) !== resolve(absScript)) {
@@ -139,7 +157,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
139
157
  // the run between the optimistic read and lease acquisition.
140
158
  assertWorkflowRunResumable(opts.coordRoot, resumeState.manifest.run_id);
141
159
  }
142
- return await executeWorkflow(scriptPath, absScript, opts, resumeState);
160
+ return await executeWorkflow(scriptPath, absScript, opts, resumeState, workContext);
143
161
  } finally {
144
162
  releaseResumeLease?.();
145
163
  }
@@ -150,6 +168,7 @@ async function executeWorkflow(
150
168
  absScript: string,
151
169
  opts: EngineOpts,
152
170
  resumeState: ReturnType<typeof assertWorkflowRunResumable> | undefined,
171
+ workContext: Readonly<WorkflowWorkContext> | undefined,
153
172
  ): Promise<RunReport> {
154
173
  const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
155
174
  if (typeof mod.default !== "function") {
@@ -198,6 +217,7 @@ async function executeWorkflow(
198
217
  schema_version: 1,
199
218
  run_id: runId,
200
219
  work_item_id: opts.workItemId,
220
+ work_context: workContext,
201
221
  name,
202
222
  started_at: startedAt,
203
223
  script: { path: absScript, sha256: workflowScriptDigest(absScript) },
@@ -762,7 +782,15 @@ async function executeWorkflow(
762
782
  });
763
783
  };
764
784
 
765
- const ctx: WorkflowContext = { agent, parallel, stage, log, evidence, authorize };
785
+ const ctx: WorkflowContext = {
786
+ work: workContext,
787
+ agent,
788
+ parallel,
789
+ stage,
790
+ log,
791
+ evidence,
792
+ authorize,
793
+ };
766
794
  try {
767
795
  if (resumeState) {
768
796
  journal("run.resume", {
@@ -774,6 +802,7 @@ async function executeWorkflow(
774
802
  journal("run.start", {
775
803
  name,
776
804
  work_item_id: opts.workItemId ?? null,
805
+ work_context: workContext ?? null,
777
806
  script: absScript,
778
807
  objective: meta.objective ?? null,
779
808
  acceptance: meta.acceptance,
@@ -800,6 +829,7 @@ async function executeWorkflow(
800
829
  proof = buildWorkflowProof({
801
830
  runId,
802
831
  workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
832
+ workContext,
803
833
  meta,
804
834
  status: "succeeded",
805
835
  startedAt,
@@ -866,6 +896,7 @@ async function executeWorkflow(
866
896
  const proof = buildWorkflowProof({
867
897
  runId,
868
898
  workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
899
+ workContext,
869
900
  meta,
870
901
  status: "failed",
871
902
  startedAt,
@@ -71,5 +71,9 @@ export type {
71
71
  WorkflowRepoSnapshot,
72
72
  WorkflowRunStatus,
73
73
  WorkflowSpecialistProfile,
74
+ WorkflowWorkContext,
75
+ } from "./types.ts";
76
+ export {
77
+ WORKFLOW_PROOF_SCHEMA_VERSION,
78
+ WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION,
74
79
  } from "./types.ts";
75
- export { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
@@ -34,8 +34,10 @@ import type {
34
34
  WorkflowProofUnknown,
35
35
  WorkflowRepoEvidence,
36
36
  WorkflowRepoSnapshot,
37
+ WorkflowWorkContext,
37
38
  } from "./types.ts";
38
39
  import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
40
+ import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
39
41
 
40
42
  const MAX_ACCEPTANCE_CRITERIA = 50;
41
43
  const MAX_EVIDENCE_RECORDS = 200;
@@ -59,6 +61,7 @@ export interface NormalizedWorkflowMeta {
59
61
  export interface BuildWorkflowProofInput {
60
62
  runId: string;
61
63
  workItemId?: string;
64
+ workContext?: Readonly<WorkflowWorkContext>;
62
65
  meta: NormalizedWorkflowMeta;
63
66
  status: "succeeded" | "failed";
64
67
  startedAt: string;
@@ -221,6 +224,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
221
224
  started_at: input.startedAt,
222
225
  ended_at: input.endedAt,
223
226
  duration_ms: input.durationMs,
227
+ work_context: input.workContext,
224
228
  objective: input.meta.objective,
225
229
  error: clippedOptional(input.error, MAX_SUMMARY_CHARS),
226
230
  result: input.result === undefined ? undefined : digestResult(input.result),
@@ -303,7 +307,14 @@ export function readWorkflowProof(coordRoot: string, runId: string): WorkflowPro
303
307
  } catch (error) {
304
308
  throw new Error(`cannot parse workflow proof at ${path}: ${(error as Error).message}`);
305
309
  }
306
- if (proof.schema_version !== WORKFLOW_PROOF_SCHEMA_VERSION || proof.run?.id !== runId) {
310
+ if (
311
+ proof.schema_version !== WORKFLOW_PROOF_SCHEMA_VERSION ||
312
+ proof.run?.id !== runId ||
313
+ (proof.run.work_context !== undefined &&
314
+ (!proof.run.work_item_id ||
315
+ proof.run.work_context.id !== proof.run.work_item_id ||
316
+ !isCanonicalWorkflowWorkContext(proof.run.work_context)))
317
+ ) {
307
318
  throw new Error(`workflow proof at ${path} has an unsupported or mismatched schema`);
308
319
  }
309
320
  return proof;
@@ -23,7 +23,8 @@ import {
23
23
  } from "../policy/index.ts";
24
24
  import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
25
25
  import { normalizeWorkflowSpecialists } from "./specialists.ts";
26
- import type { WorkflowSpecialistProfile } from "./types.ts";
26
+ import type { WorkflowSpecialistProfile, WorkflowWorkContext } from "./types.ts";
27
+ import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
27
28
 
28
29
  export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
29
30
 
@@ -34,6 +35,7 @@ export interface WorkflowRunManifest {
34
35
  schema_version: typeof WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION;
35
36
  run_id: string;
36
37
  work_item_id?: string;
38
+ work_context?: WorkflowWorkContext;
37
39
  name: string;
38
40
  started_at: string;
39
41
  script: { path: string; sha256: string };
@@ -103,6 +105,10 @@ export function readWorkflowRunManifest(coordRoot: string, runId: string): Workf
103
105
  manifest.run_id !== runId ||
104
106
  (manifest.work_item_id !== undefined &&
105
107
  !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(manifest.work_item_id)) ||
108
+ (manifest.work_context !== undefined &&
109
+ (!manifest.work_item_id ||
110
+ manifest.work_context.id !== manifest.work_item_id ||
111
+ !isCanonicalWorkflowWorkContext(manifest.work_context))) ||
106
112
  typeof manifest.name !== "string" ||
107
113
  manifest.name.length === 0 ||
108
114
  manifest.name.length > 200 ||
@@ -20,6 +20,7 @@ import type {
20
20
  import type { BillingMode, BillingProber } from "./billing.ts";
21
21
 
22
22
  export const WORKFLOW_PROOF_SCHEMA_VERSION = 1 as const;
23
+ export const WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION = 1 as const;
23
24
 
24
25
  export type EvidenceKind = "test" | "command" | "artifact" | "change" | "review" | "observation";
25
26
  export type EvidenceStatus = "passed" | "failed" | "observed" | "unknown";
@@ -155,6 +156,7 @@ export interface WorkflowProof {
155
156
  started_at: string;
156
157
  ended_at: string;
157
158
  duration_ms: number;
159
+ work_context?: WorkflowWorkContext;
158
160
  objective?: string;
159
161
  error?: string;
160
162
  result?: ResultDigest;
@@ -299,6 +301,9 @@ export type Spawner = (req: SpawnRequest) => Promise<SpawnResult>;
299
301
  /** The API surface injected into a workflow script's default export. Explicit
300
302
  * injection (no ambient globals): keeps scripts portable and unit-testable. */
301
303
  export interface WorkflowContext {
304
+ /** Frozen durable-work assignment for work-linked runs. Standalone and
305
+ * legacy-resumed workflows have no work context. */
306
+ work?: Readonly<WorkflowWorkContext>;
302
307
  /** Spawn one subagent; resolves to validated JSON (schema) or reply text. */
303
308
  agent: (prompt: string, opts?: AgentOpts) => Promise<unknown>;
304
309
  /** Run thunks with bounded concurrency; a rejected thunk resolves to null. */
@@ -326,6 +331,15 @@ export interface WorkflowModule {
326
331
  default: (ctx: WorkflowContext) => Promise<unknown>;
327
332
  }
328
333
 
334
+ /** Minimal immutable durable-work input exposed to reusable workflow code. */
335
+ export interface WorkflowWorkContext {
336
+ readonly schema_version: typeof WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION;
337
+ readonly id: string;
338
+ readonly title: string;
339
+ readonly objective: string;
340
+ readonly acceptance: readonly string[];
341
+ }
342
+
329
343
  export interface EngineOpts {
330
344
  /** Repo root whose .harnery/ receives the run journal. */
331
345
  coordRoot: string;
@@ -348,6 +362,9 @@ export interface EngineOpts {
348
362
  runId?: string;
349
363
  /** Durable objective this execution attempt belongs to. */
350
364
  workItemId?: string;
365
+ /** Frozen assignment data supplied by a durable-work host. Requires the
366
+ * matching `workItemId`; parked resume always uses the manifest copy. */
367
+ workContext?: WorkflowWorkContext;
351
368
  /** Total-agent ceiling for the run (default 50): the runaway backstop. */
352
369
  maxAgents?: number;
353
370
  /** Concurrent-subagent cap for parallel() (default 4). */
@@ -0,0 +1,66 @@
1
+ import type { WorkflowWorkContext } from "./types.ts";
2
+ import { WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION } from "./types.ts";
3
+
4
+ const WORK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
5
+ const MAX_TITLE = 200;
6
+ const MAX_OBJECTIVE = 4_000;
7
+ const MAX_ACCEPTANCE = 50;
8
+ const MAX_ACCEPTANCE_ITEM = 500;
9
+ const FIELDS = new Set(["schema_version", "id", "title", "objective", "acceptance"]);
10
+
11
+ export function normalizeWorkflowWorkContext(value: unknown): WorkflowWorkContext {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
13
+ throw new Error("workflow work context must be an object");
14
+ }
15
+ const input = value as Record<string, unknown>;
16
+ const extra = Object.keys(input).filter((key) => !FIELDS.has(key));
17
+ if (extra.length > 0) {
18
+ throw new Error(`workflow work context has unknown field ${JSON.stringify(extra[0])}`);
19
+ }
20
+ if (input.schema_version !== WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION) {
21
+ throw new Error("workflow work context has an unsupported schema");
22
+ }
23
+ const id = requiredString(input.id, "workflow work context id", 100);
24
+ if (!WORK_ID.test(id)) {
25
+ throw new Error(`invalid workflow work context id ${JSON.stringify(id)}`);
26
+ }
27
+ if (!Array.isArray(input.acceptance) || input.acceptance.length > MAX_ACCEPTANCE) {
28
+ throw new Error(
29
+ `workflow work context acceptance must contain at most ${MAX_ACCEPTANCE} criteria`,
30
+ );
31
+ }
32
+ const acceptance = input.acceptance.map((criterion, index) =>
33
+ requiredString(criterion, `workflow work context acceptance[${index}]`, MAX_ACCEPTANCE_ITEM),
34
+ );
35
+ return {
36
+ schema_version: WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION,
37
+ id,
38
+ title: requiredString(input.title, "workflow work context title", MAX_TITLE),
39
+ objective: requiredString(input.objective, "workflow work context objective", MAX_OBJECTIVE),
40
+ acceptance,
41
+ };
42
+ }
43
+
44
+ export function freezeWorkflowWorkContext(value: unknown): Readonly<WorkflowWorkContext> {
45
+ const normalized = normalizeWorkflowWorkContext(value);
46
+ Object.freeze(normalized.acceptance);
47
+ return Object.freeze(normalized);
48
+ }
49
+
50
+ export function isCanonicalWorkflowWorkContext(value: unknown): value is WorkflowWorkContext {
51
+ try {
52
+ const normalized = normalizeWorkflowWorkContext(value);
53
+ return JSON.stringify(normalized) === JSON.stringify(value);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function requiredString(value: unknown, field: string, max: number): string {
60
+ if (typeof value !== "string" || value.trim() === "") {
61
+ throw new Error(`${field} is required`);
62
+ }
63
+ const normalized = value.trim();
64
+ if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
65
+ return normalized;
66
+ }