harnery 0.20.0 → 0.22.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/README.md +1 -1
- package/dist/core/work/index.d.ts +1 -1
- package/dist/core/work/index.d.ts.map +1 -1
- package/dist/core/work/read.d.ts +1 -1
- package/dist/core/work/read.d.ts.map +1 -1
- package/dist/core/work/runner.d.ts +1 -1
- package/dist/core/work/runner.d.ts.map +1 -1
- package/dist/core/work/runner.js +61 -0
- package/dist/core/work/state.d.ts +3 -0
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +40 -6
- package/dist/core/workflow/attempt-context.d.ts +5 -0
- package/dist/core/workflow/attempt-context.d.ts.map +1 -0
- package/dist/core/workflow/attempt-context.js +189 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +47 -3
- package/dist/core/workflow/index.d.ts +3 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/proof.d.ts +3 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +14 -1
- package/dist/core/workflow/run-state.d.ts +3 -1
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +10 -0
- package/dist/core/workflow/types.d.ts +43 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +2 -0
- package/dist/core/workflow/work-context.d.ts +5 -0
- package/dist/core/workflow/work-context.d.ts.map +1 -0
- package/dist/core/workflow/work-context.js +58 -0
- package/package.json +1 -1
- package/src/core/work/index.ts +1 -0
- package/src/core/work/read.ts +1 -0
- package/src/core/work/runner.ts +76 -2
- package/src/core/work/state.ts +68 -6
- package/src/core/workflow/attempt-context.ts +215 -0
- package/src/core/workflow/engine.ts +61 -2
- package/src/core/workflow/index.ts +15 -1
- package/src/core/workflow/proof.ts +20 -1
- package/src/core/workflow/run-state.ts +17 -1
- package/src/core/workflow/types.ts +52 -0
- package/src/core/workflow/work-context.ts +66 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceSummary,
|
|
3
|
+
WorkflowAttemptContext,
|
|
4
|
+
WorkflowAttemptFailureCause,
|
|
5
|
+
WorkflowAttemptPriorContext,
|
|
6
|
+
WorkflowAttemptUnresolvedCriterion,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
import { WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
11
|
+
const ACCEPTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
12
|
+
const MAX_ATTEMPTS = 100;
|
|
13
|
+
const MAX_ERROR = 2_000;
|
|
14
|
+
const MAX_CRITERIA = 50;
|
|
15
|
+
const MAX_STATEMENT = 500;
|
|
16
|
+
const CONTEXT_FIELDS = new Set(["schema_version", "number", "trigger", "prior"]);
|
|
17
|
+
const PRIOR_FIELDS = new Set(["run_id", "causes", "error", "acceptance", "unresolved"]);
|
|
18
|
+
const SUMMARY_FIELDS = new Set(["satisfied", "unsatisfied", "unknown", "total"]);
|
|
19
|
+
const CRITERION_FIELDS = new Set(["id", "statement", "status"]);
|
|
20
|
+
const CAUSE_ORDER: readonly WorkflowAttemptFailureCause[] = [
|
|
21
|
+
"workflow_error",
|
|
22
|
+
"acceptance_unsatisfied",
|
|
23
|
+
"acceptance_unknown",
|
|
24
|
+
"lost",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function normalizeWorkflowAttemptContext(value: unknown): WorkflowAttemptContext {
|
|
28
|
+
const input = objectValue(value, "workflow attempt context");
|
|
29
|
+
rejectExtraFields(input, CONTEXT_FIELDS, "workflow attempt context");
|
|
30
|
+
if (input.schema_version !== WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION) {
|
|
31
|
+
throw new Error("workflow attempt context has an unsupported schema");
|
|
32
|
+
}
|
|
33
|
+
const number = safeInteger(input.number, "workflow attempt context number", 1, MAX_ATTEMPTS);
|
|
34
|
+
if (input.trigger !== "initial" && input.trigger !== "retry") {
|
|
35
|
+
throw new Error('workflow attempt context trigger must be "initial" or "retry"');
|
|
36
|
+
}
|
|
37
|
+
if (input.trigger === "initial") {
|
|
38
|
+
if (input.prior !== undefined) {
|
|
39
|
+
throw new Error("initial workflow attempt context cannot contain prior evidence");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
|
|
43
|
+
number,
|
|
44
|
+
trigger: "initial",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (number < 2) throw new Error("retry workflow attempt context number must be at least 2");
|
|
48
|
+
if (input.prior === undefined) {
|
|
49
|
+
throw new Error("retry workflow attempt context requires prior evidence");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
|
|
53
|
+
number,
|
|
54
|
+
trigger: "retry",
|
|
55
|
+
prior: normalizePrior(input.prior),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function freezeWorkflowAttemptContext(value: unknown): Readonly<WorkflowAttemptContext> {
|
|
60
|
+
const normalized = normalizeWorkflowAttemptContext(value);
|
|
61
|
+
if (normalized.prior) {
|
|
62
|
+
for (const criterion of normalized.prior.unresolved) Object.freeze(criterion);
|
|
63
|
+
Object.freeze(normalized.prior.causes);
|
|
64
|
+
Object.freeze(normalized.prior.acceptance);
|
|
65
|
+
Object.freeze(normalized.prior.unresolved);
|
|
66
|
+
Object.freeze(normalized.prior);
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze(normalized);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isCanonicalWorkflowAttemptContext(value: unknown): value is WorkflowAttemptContext {
|
|
72
|
+
try {
|
|
73
|
+
const normalized = normalizeWorkflowAttemptContext(value);
|
|
74
|
+
return JSON.stringify(normalized) === JSON.stringify(value);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizePrior(value: unknown): WorkflowAttemptPriorContext {
|
|
81
|
+
const input = objectValue(value, "workflow attempt prior context");
|
|
82
|
+
rejectExtraFields(input, PRIOR_FIELDS, "workflow attempt prior context");
|
|
83
|
+
const runId = requiredString(input.run_id, "workflow attempt prior run id", 200);
|
|
84
|
+
if (!RUN_ID.test(runId)) {
|
|
85
|
+
throw new Error(`invalid workflow attempt prior run id ${JSON.stringify(runId)}`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(input.causes) || input.causes.length === 0) {
|
|
88
|
+
throw new Error("workflow attempt prior causes must be a non-empty array");
|
|
89
|
+
}
|
|
90
|
+
const seen = new Set<WorkflowAttemptFailureCause>();
|
|
91
|
+
for (const cause of input.causes) {
|
|
92
|
+
if (!CAUSE_ORDER.includes(cause as WorkflowAttemptFailureCause)) {
|
|
93
|
+
throw new Error(`unsupported workflow attempt failure cause ${JSON.stringify(cause)}`);
|
|
94
|
+
}
|
|
95
|
+
if (seen.has(cause as WorkflowAttemptFailureCause)) {
|
|
96
|
+
throw new Error(`duplicate workflow attempt failure cause ${JSON.stringify(cause)}`);
|
|
97
|
+
}
|
|
98
|
+
seen.add(cause as WorkflowAttemptFailureCause);
|
|
99
|
+
}
|
|
100
|
+
const causes = CAUSE_ORDER.filter((cause) => seen.has(cause));
|
|
101
|
+
if (causes.includes("lost")) {
|
|
102
|
+
if (causes.length !== 1)
|
|
103
|
+
throw new Error("lost must be the only workflow attempt failure cause");
|
|
104
|
+
if (input.error !== undefined || input.acceptance !== undefined) {
|
|
105
|
+
throw new Error("lost workflow attempt evidence cannot contain proof-derived fields");
|
|
106
|
+
}
|
|
107
|
+
const unresolved = normalizeUnresolved(input.unresolved);
|
|
108
|
+
if (unresolved.length > 0) {
|
|
109
|
+
throw new Error("lost workflow attempt evidence cannot contain unresolved criteria");
|
|
110
|
+
}
|
|
111
|
+
return { run_id: runId, causes, unresolved };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const acceptance = normalizeAcceptanceSummary(input.acceptance);
|
|
115
|
+
const unresolved = normalizeUnresolved(input.unresolved);
|
|
116
|
+
const unsatisfied = unresolved.filter((criterion) => criterion.status === "unsatisfied").length;
|
|
117
|
+
const unknown = unresolved.filter((criterion) => criterion.status === "unknown").length;
|
|
118
|
+
if (
|
|
119
|
+
unsatisfied !== acceptance.unsatisfied ||
|
|
120
|
+
unknown !== acceptance.unknown ||
|
|
121
|
+
causes.includes("acceptance_unsatisfied") !== acceptance.unsatisfied > 0 ||
|
|
122
|
+
causes.includes("acceptance_unknown") !== acceptance.unknown > 0
|
|
123
|
+
) {
|
|
124
|
+
throw new Error("workflow attempt failure evidence does not match acceptance");
|
|
125
|
+
}
|
|
126
|
+
if (!causes.includes("workflow_error") && acceptance.unsatisfied + acceptance.unknown === 0) {
|
|
127
|
+
throw new Error("workflow attempt prior context does not describe a failure");
|
|
128
|
+
}
|
|
129
|
+
const error =
|
|
130
|
+
input.error === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: requiredString(input.error, "workflow attempt prior error", MAX_ERROR);
|
|
133
|
+
return {
|
|
134
|
+
run_id: runId,
|
|
135
|
+
causes,
|
|
136
|
+
...(error === undefined ? {} : { error }),
|
|
137
|
+
acceptance,
|
|
138
|
+
unresolved,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeAcceptanceSummary(value: unknown): AcceptanceSummary {
|
|
143
|
+
const input = objectValue(value, "workflow attempt acceptance summary");
|
|
144
|
+
rejectExtraFields(input, SUMMARY_FIELDS, "workflow attempt acceptance summary");
|
|
145
|
+
const satisfied = safeInteger(input.satisfied, "acceptance satisfied", 0, MAX_CRITERIA);
|
|
146
|
+
const unsatisfied = safeInteger(input.unsatisfied, "acceptance unsatisfied", 0, MAX_CRITERIA);
|
|
147
|
+
const unknown = safeInteger(input.unknown, "acceptance unknown", 0, MAX_CRITERIA);
|
|
148
|
+
const total = safeInteger(input.total, "acceptance total", 0, MAX_CRITERIA);
|
|
149
|
+
if (satisfied + unsatisfied + unknown !== total) {
|
|
150
|
+
throw new Error("workflow attempt acceptance summary total does not match its counts");
|
|
151
|
+
}
|
|
152
|
+
return { satisfied, unsatisfied, unknown, total };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeUnresolved(value: unknown): WorkflowAttemptUnresolvedCriterion[] {
|
|
156
|
+
if (!Array.isArray(value) || value.length > MAX_CRITERIA) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`workflow attempt unresolved criteria must contain at most ${MAX_CRITERIA} entries`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const ids = new Set<string>();
|
|
162
|
+
return value.map((candidate, index) => {
|
|
163
|
+
const input = objectValue(candidate, `workflow attempt unresolved[${index}]`);
|
|
164
|
+
rejectExtraFields(input, CRITERION_FIELDS, `workflow attempt unresolved[${index}]`);
|
|
165
|
+
const id = requiredString(input.id, `workflow attempt unresolved[${index}].id`, 64);
|
|
166
|
+
if (!ACCEPTANCE_ID.test(id)) {
|
|
167
|
+
throw new Error(`invalid workflow attempt acceptance id ${JSON.stringify(id)}`);
|
|
168
|
+
}
|
|
169
|
+
if (ids.has(id))
|
|
170
|
+
throw new Error(`duplicate workflow attempt acceptance id ${JSON.stringify(id)}`);
|
|
171
|
+
ids.add(id);
|
|
172
|
+
if (input.status !== "unsatisfied" && input.status !== "unknown") {
|
|
173
|
+
throw new Error(`workflow attempt unresolved[${index}].status is invalid`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
statement: requiredString(
|
|
178
|
+
input.statement,
|
|
179
|
+
`workflow attempt unresolved[${index}].statement`,
|
|
180
|
+
MAX_STATEMENT,
|
|
181
|
+
),
|
|
182
|
+
status: input.status,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function objectValue(value: unknown, field: string): Record<string, unknown> {
|
|
188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
189
|
+
throw new Error(`${field} must be an object`);
|
|
190
|
+
}
|
|
191
|
+
return value as Record<string, unknown>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rejectExtraFields(
|
|
195
|
+
value: Record<string, unknown>,
|
|
196
|
+
allowed: ReadonlySet<string>,
|
|
197
|
+
field: string,
|
|
198
|
+
): void {
|
|
199
|
+
const extra = Object.keys(value).find((key) => !allowed.has(key));
|
|
200
|
+
if (extra) throw new Error(`${field} has unknown field ${JSON.stringify(extra)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requiredString(value: unknown, field: string, max: number): string {
|
|
204
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} is required`);
|
|
205
|
+
const normalized = value.trim();
|
|
206
|
+
if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
|
|
207
|
+
return normalized;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function safeInteger(value: unknown, field: string, min: number, max: number): number {
|
|
211
|
+
if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max) {
|
|
212
|
+
throw new Error(`${field} must be an integer from ${min} to ${max}`);
|
|
213
|
+
}
|
|
214
|
+
return value as number;
|
|
215
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
summarizePolicyRequest,
|
|
37
37
|
} from "../policy/index.ts";
|
|
38
38
|
import { assertWorkflowRunId, createWorkflowApproval } from "./approvals.ts";
|
|
39
|
+
import { freezeWorkflowAttemptContext } from "./attempt-context.ts";
|
|
39
40
|
import { type BillingProbe, probeBilling } from "./billing.ts";
|
|
40
41
|
import {
|
|
41
42
|
buildWorkflowProof,
|
|
@@ -60,13 +61,16 @@ import type {
|
|
|
60
61
|
SpawnResult,
|
|
61
62
|
StageSchema,
|
|
62
63
|
WorkflowAgentProof,
|
|
64
|
+
WorkflowAttemptContext,
|
|
63
65
|
WorkflowContext,
|
|
64
66
|
WorkflowEvidenceRecord,
|
|
65
67
|
WorkflowModule,
|
|
66
68
|
WorkflowProof,
|
|
69
|
+
WorkflowWorkContext,
|
|
67
70
|
} from "./types.ts";
|
|
68
71
|
import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
|
|
69
72
|
import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
|
|
73
|
+
import { freezeWorkflowWorkContext } from "./work-context.ts";
|
|
70
74
|
|
|
71
75
|
const DEFAULT_MAX_AGENTS = 50;
|
|
72
76
|
const DEFAULT_CONCURRENCY = 4;
|
|
@@ -110,6 +114,29 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
110
114
|
if (opts.workItemId && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(opts.workItemId)) {
|
|
111
115
|
throw new Error(`invalid work item id ${JSON.stringify(opts.workItemId)}`);
|
|
112
116
|
}
|
|
117
|
+
if (opts.resumeRunId && opts.workContext !== undefined) {
|
|
118
|
+
throw new Error("parked workflow resume uses the frozen manifest work context");
|
|
119
|
+
}
|
|
120
|
+
if (opts.resumeRunId && opts.attemptContext !== undefined) {
|
|
121
|
+
throw new Error("parked workflow resume uses the frozen manifest attempt context");
|
|
122
|
+
}
|
|
123
|
+
const requestedWorkContext =
|
|
124
|
+
opts.workContext === undefined ? undefined : freezeWorkflowWorkContext(opts.workContext);
|
|
125
|
+
const requestedAttemptContext =
|
|
126
|
+
opts.attemptContext === undefined
|
|
127
|
+
? undefined
|
|
128
|
+
: freezeWorkflowAttemptContext(opts.attemptContext);
|
|
129
|
+
if (requestedWorkContext && !opts.workItemId) {
|
|
130
|
+
throw new Error("workflow work context requires a work item id");
|
|
131
|
+
}
|
|
132
|
+
if (requestedWorkContext && requestedWorkContext.id !== opts.workItemId) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`workflow work context ${requestedWorkContext.id} does not match work item ${opts.workItemId}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (requestedAttemptContext && !requestedWorkContext) {
|
|
138
|
+
throw new Error("workflow attempt context requires work context");
|
|
139
|
+
}
|
|
113
140
|
if (opts.resumeRunId && opts.resumeFrom) {
|
|
114
141
|
throw new Error("resumeRunId and resumeFrom are mutually exclusive");
|
|
115
142
|
}
|
|
@@ -123,6 +150,12 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
123
150
|
) {
|
|
124
151
|
throw new Error(`workflow run ${opts.resumeRunId} belongs to a different work item`);
|
|
125
152
|
}
|
|
153
|
+
const workContext = resumeState?.manifest.work_context
|
|
154
|
+
? freezeWorkflowWorkContext(resumeState.manifest.work_context)
|
|
155
|
+
: requestedWorkContext;
|
|
156
|
+
const attemptContext = resumeState?.manifest.attempt_context
|
|
157
|
+
? freezeWorkflowAttemptContext(resumeState.manifest.attempt_context)
|
|
158
|
+
: requestedAttemptContext;
|
|
126
159
|
const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
|
|
127
160
|
if (resumeState) {
|
|
128
161
|
if (resolve(resumeState.manifest.script.path) !== resolve(absScript)) {
|
|
@@ -139,7 +172,14 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
139
172
|
// the run between the optimistic read and lease acquisition.
|
|
140
173
|
assertWorkflowRunResumable(opts.coordRoot, resumeState.manifest.run_id);
|
|
141
174
|
}
|
|
142
|
-
return await executeWorkflow(
|
|
175
|
+
return await executeWorkflow(
|
|
176
|
+
scriptPath,
|
|
177
|
+
absScript,
|
|
178
|
+
opts,
|
|
179
|
+
resumeState,
|
|
180
|
+
workContext,
|
|
181
|
+
attemptContext,
|
|
182
|
+
);
|
|
143
183
|
} finally {
|
|
144
184
|
releaseResumeLease?.();
|
|
145
185
|
}
|
|
@@ -150,6 +190,8 @@ async function executeWorkflow(
|
|
|
150
190
|
absScript: string,
|
|
151
191
|
opts: EngineOpts,
|
|
152
192
|
resumeState: ReturnType<typeof assertWorkflowRunResumable> | undefined,
|
|
193
|
+
workContext: Readonly<WorkflowWorkContext> | undefined,
|
|
194
|
+
attemptContext: Readonly<WorkflowAttemptContext> | undefined,
|
|
153
195
|
): Promise<RunReport> {
|
|
154
196
|
const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
|
|
155
197
|
if (typeof mod.default !== "function") {
|
|
@@ -198,6 +240,8 @@ async function executeWorkflow(
|
|
|
198
240
|
schema_version: 1,
|
|
199
241
|
run_id: runId,
|
|
200
242
|
work_item_id: opts.workItemId,
|
|
243
|
+
work_context: workContext,
|
|
244
|
+
attempt_context: attemptContext,
|
|
201
245
|
name,
|
|
202
246
|
started_at: startedAt,
|
|
203
247
|
script: { path: absScript, sha256: workflowScriptDigest(absScript) },
|
|
@@ -762,7 +806,16 @@ async function executeWorkflow(
|
|
|
762
806
|
});
|
|
763
807
|
};
|
|
764
808
|
|
|
765
|
-
const ctx: WorkflowContext = {
|
|
809
|
+
const ctx: WorkflowContext = {
|
|
810
|
+
work: workContext,
|
|
811
|
+
attempt: attemptContext,
|
|
812
|
+
agent,
|
|
813
|
+
parallel,
|
|
814
|
+
stage,
|
|
815
|
+
log,
|
|
816
|
+
evidence,
|
|
817
|
+
authorize,
|
|
818
|
+
};
|
|
766
819
|
try {
|
|
767
820
|
if (resumeState) {
|
|
768
821
|
journal("run.resume", {
|
|
@@ -774,6 +827,8 @@ async function executeWorkflow(
|
|
|
774
827
|
journal("run.start", {
|
|
775
828
|
name,
|
|
776
829
|
work_item_id: opts.workItemId ?? null,
|
|
830
|
+
work_context: workContext ?? null,
|
|
831
|
+
attempt_context: attemptContext ?? null,
|
|
777
832
|
script: absScript,
|
|
778
833
|
objective: meta.objective ?? null,
|
|
779
834
|
acceptance: meta.acceptance,
|
|
@@ -800,6 +855,8 @@ async function executeWorkflow(
|
|
|
800
855
|
proof = buildWorkflowProof({
|
|
801
856
|
runId,
|
|
802
857
|
workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
|
|
858
|
+
workContext,
|
|
859
|
+
attemptContext,
|
|
803
860
|
meta,
|
|
804
861
|
status: "succeeded",
|
|
805
862
|
startedAt,
|
|
@@ -866,6 +923,8 @@ async function executeWorkflow(
|
|
|
866
923
|
const proof = buildWorkflowProof({
|
|
867
924
|
runId,
|
|
868
925
|
workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
|
|
926
|
+
workContext,
|
|
927
|
+
attemptContext,
|
|
869
928
|
meta,
|
|
870
929
|
status: "failed",
|
|
871
930
|
startedAt,
|
|
@@ -16,6 +16,11 @@ export {
|
|
|
16
16
|
WORKFLOW_APPROVAL_SCHEMA_VERSION,
|
|
17
17
|
workflowApprovalId,
|
|
18
18
|
} from "./approvals.ts";
|
|
19
|
+
export {
|
|
20
|
+
freezeWorkflowAttemptContext,
|
|
21
|
+
isCanonicalWorkflowAttemptContext,
|
|
22
|
+
normalizeWorkflowAttemptContext,
|
|
23
|
+
} from "./attempt-context.ts";
|
|
19
24
|
export { runWorkflow, WorkflowParkedError, WorkflowRunError } from "./engine.ts";
|
|
20
25
|
export {
|
|
21
26
|
buildWorkflowProof,
|
|
@@ -59,6 +64,10 @@ export type {
|
|
|
59
64
|
StageSchema,
|
|
60
65
|
WorkflowAgentProof,
|
|
61
66
|
WorkflowApprovalMode,
|
|
67
|
+
WorkflowAttemptContext,
|
|
68
|
+
WorkflowAttemptFailureCause,
|
|
69
|
+
WorkflowAttemptPriorContext,
|
|
70
|
+
WorkflowAttemptUnresolvedCriterion,
|
|
62
71
|
WorkflowContext,
|
|
63
72
|
WorkflowEvidenceInput,
|
|
64
73
|
WorkflowEvidenceRecord,
|
|
@@ -71,5 +80,10 @@ export type {
|
|
|
71
80
|
WorkflowRepoSnapshot,
|
|
72
81
|
WorkflowRunStatus,
|
|
73
82
|
WorkflowSpecialistProfile,
|
|
83
|
+
WorkflowWorkContext,
|
|
84
|
+
} from "./types.ts";
|
|
85
|
+
export {
|
|
86
|
+
WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
|
|
87
|
+
WORKFLOW_PROOF_SCHEMA_VERSION,
|
|
88
|
+
WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION,
|
|
74
89
|
} from "./types.ts";
|
|
75
|
-
export { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
PolicyNetworkAccess,
|
|
19
19
|
} from "../policy/index.ts";
|
|
20
20
|
import { policyDigest } from "../policy/index.ts";
|
|
21
|
+
import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
|
|
21
22
|
import type {
|
|
22
23
|
AcceptanceCriterion,
|
|
23
24
|
AcceptanceResult,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
HarnessEvidenceCoverage,
|
|
27
28
|
ResultDigest,
|
|
28
29
|
WorkflowAgentProof,
|
|
30
|
+
WorkflowAttemptContext,
|
|
29
31
|
WorkflowEvidenceInput,
|
|
30
32
|
WorkflowEvidenceRecord,
|
|
31
33
|
WorkflowMeta,
|
|
@@ -34,8 +36,10 @@ import type {
|
|
|
34
36
|
WorkflowProofUnknown,
|
|
35
37
|
WorkflowRepoEvidence,
|
|
36
38
|
WorkflowRepoSnapshot,
|
|
39
|
+
WorkflowWorkContext,
|
|
37
40
|
} from "./types.ts";
|
|
38
41
|
import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
|
|
42
|
+
import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
|
|
39
43
|
|
|
40
44
|
const MAX_ACCEPTANCE_CRITERIA = 50;
|
|
41
45
|
const MAX_EVIDENCE_RECORDS = 200;
|
|
@@ -59,6 +63,8 @@ export interface NormalizedWorkflowMeta {
|
|
|
59
63
|
export interface BuildWorkflowProofInput {
|
|
60
64
|
runId: string;
|
|
61
65
|
workItemId?: string;
|
|
66
|
+
workContext?: Readonly<WorkflowWorkContext>;
|
|
67
|
+
attemptContext?: Readonly<WorkflowAttemptContext>;
|
|
62
68
|
meta: NormalizedWorkflowMeta;
|
|
63
69
|
status: "succeeded" | "failed";
|
|
64
70
|
startedAt: string;
|
|
@@ -221,6 +227,8 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
|
|
|
221
227
|
started_at: input.startedAt,
|
|
222
228
|
ended_at: input.endedAt,
|
|
223
229
|
duration_ms: input.durationMs,
|
|
230
|
+
work_context: input.workContext,
|
|
231
|
+
attempt_context: input.attemptContext,
|
|
224
232
|
objective: input.meta.objective,
|
|
225
233
|
error: clippedOptional(input.error, MAX_SUMMARY_CHARS),
|
|
226
234
|
result: input.result === undefined ? undefined : digestResult(input.result),
|
|
@@ -303,7 +311,18 @@ export function readWorkflowProof(coordRoot: string, runId: string): WorkflowPro
|
|
|
303
311
|
} catch (error) {
|
|
304
312
|
throw new Error(`cannot parse workflow proof at ${path}: ${(error as Error).message}`);
|
|
305
313
|
}
|
|
306
|
-
if (
|
|
314
|
+
if (
|
|
315
|
+
proof.schema_version !== WORKFLOW_PROOF_SCHEMA_VERSION ||
|
|
316
|
+
proof.run?.id !== runId ||
|
|
317
|
+
(proof.run.work_context !== undefined &&
|
|
318
|
+
(!proof.run.work_item_id ||
|
|
319
|
+
proof.run.work_context.id !== proof.run.work_item_id ||
|
|
320
|
+
!isCanonicalWorkflowWorkContext(proof.run.work_context))) ||
|
|
321
|
+
(proof.run.attempt_context !== undefined &&
|
|
322
|
+
(!proof.run.work_item_id ||
|
|
323
|
+
!proof.run.work_context ||
|
|
324
|
+
!isCanonicalWorkflowAttemptContext(proof.run.attempt_context)))
|
|
325
|
+
) {
|
|
307
326
|
throw new Error(`workflow proof at ${path} has an unsupported or mismatched schema`);
|
|
308
327
|
}
|
|
309
328
|
return proof;
|
|
@@ -22,8 +22,14 @@ import {
|
|
|
22
22
|
policyDigest,
|
|
23
23
|
} from "../policy/index.ts";
|
|
24
24
|
import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
|
|
25
|
+
import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
|
|
25
26
|
import { normalizeWorkflowSpecialists } from "./specialists.ts";
|
|
26
|
-
import type {
|
|
27
|
+
import type {
|
|
28
|
+
WorkflowAttemptContext,
|
|
29
|
+
WorkflowSpecialistProfile,
|
|
30
|
+
WorkflowWorkContext,
|
|
31
|
+
} from "./types.ts";
|
|
32
|
+
import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
|
|
27
33
|
|
|
28
34
|
export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
|
|
29
35
|
|
|
@@ -34,6 +40,8 @@ export interface WorkflowRunManifest {
|
|
|
34
40
|
schema_version: typeof WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION;
|
|
35
41
|
run_id: string;
|
|
36
42
|
work_item_id?: string;
|
|
43
|
+
work_context?: WorkflowWorkContext;
|
|
44
|
+
attempt_context?: WorkflowAttemptContext;
|
|
37
45
|
name: string;
|
|
38
46
|
started_at: string;
|
|
39
47
|
script: { path: string; sha256: string };
|
|
@@ -103,6 +111,14 @@ export function readWorkflowRunManifest(coordRoot: string, runId: string): Workf
|
|
|
103
111
|
manifest.run_id !== runId ||
|
|
104
112
|
(manifest.work_item_id !== undefined &&
|
|
105
113
|
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(manifest.work_item_id)) ||
|
|
114
|
+
(manifest.work_context !== undefined &&
|
|
115
|
+
(!manifest.work_item_id ||
|
|
116
|
+
manifest.work_context.id !== manifest.work_item_id ||
|
|
117
|
+
!isCanonicalWorkflowWorkContext(manifest.work_context))) ||
|
|
118
|
+
(manifest.attempt_context !== undefined &&
|
|
119
|
+
(!manifest.work_item_id ||
|
|
120
|
+
!manifest.work_context ||
|
|
121
|
+
!isCanonicalWorkflowAttemptContext(manifest.attempt_context))) ||
|
|
106
122
|
typeof manifest.name !== "string" ||
|
|
107
123
|
manifest.name.length === 0 ||
|
|
108
124
|
manifest.name.length > 200 ||
|
|
@@ -20,6 +20,8 @@ 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;
|
|
24
|
+
export const WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION = 1 as const;
|
|
23
25
|
|
|
24
26
|
export type EvidenceKind = "test" | "command" | "artifact" | "change" | "review" | "observation";
|
|
25
27
|
export type EvidenceStatus = "passed" | "failed" | "observed" | "unknown";
|
|
@@ -155,6 +157,8 @@ export interface WorkflowProof {
|
|
|
155
157
|
started_at: string;
|
|
156
158
|
ended_at: string;
|
|
157
159
|
duration_ms: number;
|
|
160
|
+
work_context?: WorkflowWorkContext;
|
|
161
|
+
attempt_context?: WorkflowAttemptContext;
|
|
158
162
|
objective?: string;
|
|
159
163
|
error?: string;
|
|
160
164
|
result?: ResultDigest;
|
|
@@ -299,6 +303,12 @@ export type Spawner = (req: SpawnRequest) => Promise<SpawnResult>;
|
|
|
299
303
|
/** The API surface injected into a workflow script's default export. Explicit
|
|
300
304
|
* injection (no ambient globals): keeps scripts portable and unit-testable. */
|
|
301
305
|
export interface WorkflowContext {
|
|
306
|
+
/** Frozen durable-work assignment for work-linked runs. Standalone and
|
|
307
|
+
* legacy-resumed workflows have no work context. */
|
|
308
|
+
work?: Readonly<WorkflowWorkContext>;
|
|
309
|
+
/** Frozen attempt identity and, on retry, a bounded synopsis of prior
|
|
310
|
+
* terminal evidence. Absent for standalone and legacy-resumed workflows. */
|
|
311
|
+
attempt?: Readonly<WorkflowAttemptContext>;
|
|
302
312
|
/** Spawn one subagent; resolves to validated JSON (schema) or reply text. */
|
|
303
313
|
agent: (prompt: string, opts?: AgentOpts) => Promise<unknown>;
|
|
304
314
|
/** Run thunks with bounded concurrency; a rejected thunk resolves to null. */
|
|
@@ -326,6 +336,42 @@ export interface WorkflowModule {
|
|
|
326
336
|
default: (ctx: WorkflowContext) => Promise<unknown>;
|
|
327
337
|
}
|
|
328
338
|
|
|
339
|
+
/** Minimal immutable durable-work input exposed to reusable workflow code. */
|
|
340
|
+
export interface WorkflowWorkContext {
|
|
341
|
+
readonly schema_version: typeof WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION;
|
|
342
|
+
readonly id: string;
|
|
343
|
+
readonly title: string;
|
|
344
|
+
readonly objective: string;
|
|
345
|
+
readonly acceptance: readonly string[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export type WorkflowAttemptFailureCause =
|
|
349
|
+
| "workflow_error"
|
|
350
|
+
| "acceptance_unsatisfied"
|
|
351
|
+
| "acceptance_unknown"
|
|
352
|
+
| "lost";
|
|
353
|
+
|
|
354
|
+
export interface WorkflowAttemptUnresolvedCriterion {
|
|
355
|
+
readonly id: string;
|
|
356
|
+
readonly statement: string;
|
|
357
|
+
readonly status: "unsatisfied" | "unknown";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface WorkflowAttemptPriorContext {
|
|
361
|
+
readonly run_id: string;
|
|
362
|
+
readonly causes: readonly WorkflowAttemptFailureCause[];
|
|
363
|
+
readonly error?: string;
|
|
364
|
+
readonly acceptance?: Readonly<AcceptanceSummary>;
|
|
365
|
+
readonly unresolved: readonly WorkflowAttemptUnresolvedCriterion[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface WorkflowAttemptContext {
|
|
369
|
+
readonly schema_version: typeof WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION;
|
|
370
|
+
readonly number: number;
|
|
371
|
+
readonly trigger: "initial" | "retry";
|
|
372
|
+
readonly prior?: Readonly<WorkflowAttemptPriorContext>;
|
|
373
|
+
}
|
|
374
|
+
|
|
329
375
|
export interface EngineOpts {
|
|
330
376
|
/** Repo root whose .harnery/ receives the run journal. */
|
|
331
377
|
coordRoot: string;
|
|
@@ -348,6 +394,12 @@ export interface EngineOpts {
|
|
|
348
394
|
runId?: string;
|
|
349
395
|
/** Durable objective this execution attempt belongs to. */
|
|
350
396
|
workItemId?: string;
|
|
397
|
+
/** Frozen assignment data supplied by a durable-work host. Requires the
|
|
398
|
+
* matching `workItemId`; parked resume always uses the manifest copy. */
|
|
399
|
+
workContext?: WorkflowWorkContext;
|
|
400
|
+
/** Frozen attempt data supplied by a durable-work host. Requires matching
|
|
401
|
+
* work item and work contexts; parked resume uses the manifest copy. */
|
|
402
|
+
attemptContext?: WorkflowAttemptContext;
|
|
351
403
|
/** Total-agent ceiling for the run (default 50): the runaway backstop. */
|
|
352
404
|
maxAgents?: number;
|
|
353
405
|
/** 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
|
+
}
|