harnery 0.16.0 → 0.17.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/commands/supervisor.d.ts.map +1 -1
- package/dist/commands/supervisor.js +184 -3
- package/dist/core/supervisor/index.d.ts +3 -0
- package/dist/core/supervisor/index.d.ts.map +1 -1
- package/dist/core/supervisor/index.js +3 -0
- package/dist/core/supervisor/plan-api.d.ts +16 -0
- package/dist/core/supervisor/plan-api.d.ts.map +1 -0
- package/dist/core/supervisor/plan-api.js +34 -0
- package/dist/core/supervisor/plan-read.d.ts +4 -0
- package/dist/core/supervisor/plan-read.d.ts.map +1 -0
- package/dist/core/supervisor/plan-read.js +269 -0
- package/dist/core/supervisor/plan-types.d.ts +113 -0
- package/dist/core/supervisor/plan-types.d.ts.map +1 -0
- package/dist/core/supervisor/plan-types.js +14 -0
- package/dist/core/supervisor/planning.d.ts +26 -0
- package/dist/core/supervisor/planning.d.ts.map +1 -0
- package/dist/core/supervisor/planning.js +486 -0
- package/dist/core/supervisor/read.d.ts +2 -0
- package/dist/core/supervisor/read.d.ts.map +1 -1
- package/dist/core/supervisor/read.js +2 -0
- package/dist/core/supervisor/runner.d.ts +3 -0
- package/dist/core/supervisor/runner.d.ts.map +1 -1
- package/dist/core/supervisor/runner.js +37 -7
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +7 -0
- package/dist/core/supervisor/state.d.ts +11 -1
- package/dist/core/supervisor/state.d.ts.map +1 -1
- package/dist/core/supervisor/state.js +186 -7
- package/package.json +1 -1
- package/src/commands/supervisor.ts +229 -2
- package/src/core/supervisor/index.ts +18 -0
- package/src/core/supervisor/plan-api.ts +47 -0
- package/src/core/supervisor/plan-read.ts +299 -0
- package/src/core/supervisor/plan-types.ts +144 -0
- package/src/core/supervisor/planning.ts +576 -0
- package/src/core/supervisor/read.ts +15 -0
- package/src/core/supervisor/runner.ts +39 -2
- package/src/core/supervisor/service.ts +7 -0
- package/src/core/supervisor/state.ts +235 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
3
|
import type { Command } from "commander";
|
|
4
4
|
import type { EmitContext } from "../commander.ts";
|
|
5
5
|
import { workflowSubscriptionOnly } from "../core/config.ts";
|
|
@@ -8,15 +8,21 @@ import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
|
|
|
8
8
|
import type { PolicyIsolation } from "../core/policy/index.ts";
|
|
9
9
|
import { loadPolicyFile } from "../core/policy/index.ts";
|
|
10
10
|
import {
|
|
11
|
+
approveSupervisorPlan,
|
|
12
|
+
type CreateSupervisorReplanningInput,
|
|
11
13
|
configureSupervisorService,
|
|
12
14
|
createSupervisor,
|
|
13
15
|
listSupervisors,
|
|
14
16
|
readSupervisor,
|
|
17
|
+
readSupervisorPlan,
|
|
15
18
|
readSupervisorServiceConfig,
|
|
16
19
|
readSupervisorServiceStatus,
|
|
20
|
+
rejectSupervisorPlan,
|
|
17
21
|
requestSupervisorServiceStop,
|
|
18
22
|
runSupervisor,
|
|
19
23
|
runSupervisorServiceDaemon,
|
|
24
|
+
type SupervisorPlanOutcome,
|
|
25
|
+
type SupervisorPlanRecord,
|
|
20
26
|
type SupervisorRecord,
|
|
21
27
|
type SupervisorRunReport,
|
|
22
28
|
type SupervisorServiceConfig,
|
|
@@ -39,6 +45,7 @@ interface CreateOpts {
|
|
|
39
45
|
acceptPassingProof?: boolean;
|
|
40
46
|
resumeApproved?: boolean;
|
|
41
47
|
retryBlocked?: boolean;
|
|
48
|
+
replanning?: string;
|
|
42
49
|
json?: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -82,6 +89,7 @@ export function registerSupervisorCommand(program: Command, emit: EmitContext):
|
|
|
82
89
|
.option("--accept-passing-proof", "Allow the supervisor to explicitly accept passing proof")
|
|
83
90
|
.option("--no-resume-approved", "Stop after an approval instead of resuming its parked run")
|
|
84
91
|
.option("--retry-blocked", "Allow bounded retry of blocked work")
|
|
92
|
+
.option("--replanning <file>", "Frozen planner policy and allowed workflow-template catalog")
|
|
85
93
|
.option("--json", "Emit the complete supervisor record as JSON")
|
|
86
94
|
.action((rootWorkId: string, opts: CreateOpts) => {
|
|
87
95
|
withSupervisorRoot(emit, (coordRoot) => {
|
|
@@ -113,6 +121,7 @@ export function registerSupervisorCommand(program: Command, emit: EmitContext):
|
|
|
113
121
|
resume_approved: opts.resumeApproved,
|
|
114
122
|
retry_blocked: opts.retryBlocked,
|
|
115
123
|
},
|
|
124
|
+
replanning: opts.replanning ? readReplanningFile(opts.replanning) : undefined,
|
|
116
125
|
}),
|
|
117
126
|
opts.json,
|
|
118
127
|
emit,
|
|
@@ -149,11 +158,102 @@ export function registerSupervisorCommand(program: Command, emit: EmitContext):
|
|
|
149
158
|
});
|
|
150
159
|
});
|
|
151
160
|
|
|
161
|
+
registerPlanCommand(supervisor, emit);
|
|
152
162
|
registerServiceCommand(supervisor, registry, emit);
|
|
153
163
|
registerRunCommand(supervisor, "tick", registry, emit);
|
|
154
164
|
registerRunCommand(supervisor, "run", registry, emit);
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
function registerPlanCommand(supervisor: Command, emit: EmitContext): void {
|
|
168
|
+
const plan = supervisor
|
|
169
|
+
.command("plan")
|
|
170
|
+
.description("Inspect and resolve bounded planner proposals for a durable goal.");
|
|
171
|
+
|
|
172
|
+
plan
|
|
173
|
+
.command("list <goal-id>")
|
|
174
|
+
.description("List append-only replanning attempts for one goal.")
|
|
175
|
+
.option("--json", "Emit complete plan records as JSON")
|
|
176
|
+
.action((goalId: string, opts: { json?: boolean }) => {
|
|
177
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
178
|
+
const plans = readSupervisor(coordRoot, goalId).plans;
|
|
179
|
+
if (opts.json) {
|
|
180
|
+
emit.config({ format: "json" });
|
|
181
|
+
emit.data(plans);
|
|
182
|
+
} else if (plans.length === 0) {
|
|
183
|
+
emit.text(`supervisor ${goalId} has no replanning attempts\n`);
|
|
184
|
+
} else {
|
|
185
|
+
emit.text(`${plans.map(renderPlanRow).join("\n")}\n`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
plan
|
|
191
|
+
.command("show <goal-id> <plan-id>")
|
|
192
|
+
.description("Show one planner request, schema-gated proposal, and audit events.")
|
|
193
|
+
.option("--json", "Emit the complete plan record as JSON")
|
|
194
|
+
.action((goalId: string, planId: string, opts: { json?: boolean }) => {
|
|
195
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
196
|
+
emitPlan(readSupervisorPlan(coordRoot, goalId, planId), opts.json, emit);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
plan
|
|
201
|
+
.command("approve <goal-id> <plan-id>")
|
|
202
|
+
.description("Materialize a reviewed proposal and advance the active immutable root.")
|
|
203
|
+
.option("--actor <name>", "Actor recorded on the plan decision")
|
|
204
|
+
.option("--reason <text>", "Reason for approval")
|
|
205
|
+
.option("--json", "Emit the plan outcome as JSON")
|
|
206
|
+
.action(
|
|
207
|
+
(
|
|
208
|
+
goalId: string,
|
|
209
|
+
planId: string,
|
|
210
|
+
opts: { actor?: string; reason?: string; json?: boolean },
|
|
211
|
+
) => {
|
|
212
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
213
|
+
emitPlanOutcome(
|
|
214
|
+
approveSupervisorPlan({
|
|
215
|
+
coordRoot,
|
|
216
|
+
goalId,
|
|
217
|
+
planId,
|
|
218
|
+
actor: opts.actor,
|
|
219
|
+
reason: opts.reason,
|
|
220
|
+
}),
|
|
221
|
+
opts.json,
|
|
222
|
+
emit,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
plan
|
|
229
|
+
.command("reject <goal-id> <plan-id>")
|
|
230
|
+
.description("Reject a pending proposal without mutating durable work.")
|
|
231
|
+
.requiredOption("--reason <text>", "Reason the proposal is rejected")
|
|
232
|
+
.option("--actor <name>", "Actor recorded on the plan decision")
|
|
233
|
+
.option("--json", "Emit the plan outcome as JSON")
|
|
234
|
+
.action(
|
|
235
|
+
(
|
|
236
|
+
goalId: string,
|
|
237
|
+
planId: string,
|
|
238
|
+
opts: { actor?: string; reason: string; json?: boolean },
|
|
239
|
+
) => {
|
|
240
|
+
withSupervisorRoot(emit, (coordRoot) => {
|
|
241
|
+
emitPlanOutcome(
|
|
242
|
+
rejectSupervisorPlan({
|
|
243
|
+
coordRoot,
|
|
244
|
+
goalId,
|
|
245
|
+
planId,
|
|
246
|
+
actor: opts.actor,
|
|
247
|
+
reason: opts.reason,
|
|
248
|
+
}),
|
|
249
|
+
opts.json,
|
|
250
|
+
emit,
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
157
257
|
function registerServiceCommand(
|
|
158
258
|
supervisor: Command,
|
|
159
259
|
registry: ReturnType<typeof createBuiltinHarnessRegistry>,
|
|
@@ -467,6 +567,65 @@ function readTeamFile(path: string): Record<string, WorkflowSpecialistProfile> {
|
|
|
467
567
|
return value as Record<string, WorkflowSpecialistProfile>;
|
|
468
568
|
}
|
|
469
569
|
|
|
570
|
+
function readReplanningFile(path: string): CreateSupervisorReplanningInput {
|
|
571
|
+
const absolute = resolve(path);
|
|
572
|
+
let value: unknown;
|
|
573
|
+
try {
|
|
574
|
+
value = JSON.parse(readFileSync(absolute, "utf8"));
|
|
575
|
+
} catch (error) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`cannot read supervisor replanning policy at ${absolute}: ${(error as Error).message}`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
581
|
+
throw new Error("supervisor replanning policy must be a JSON object");
|
|
582
|
+
}
|
|
583
|
+
const config = value as Record<string, unknown>;
|
|
584
|
+
if (typeof config.planner_specialist !== "string") {
|
|
585
|
+
throw new Error("supervisor replanning planner_specialist must be a string");
|
|
586
|
+
}
|
|
587
|
+
if (
|
|
588
|
+
!config.templates ||
|
|
589
|
+
typeof config.templates !== "object" ||
|
|
590
|
+
Array.isArray(config.templates)
|
|
591
|
+
) {
|
|
592
|
+
throw new Error("supervisor replanning templates must be an object");
|
|
593
|
+
}
|
|
594
|
+
const base = dirname(absolute);
|
|
595
|
+
const templates = Object.fromEntries(
|
|
596
|
+
Object.entries(config.templates as Record<string, unknown>).map(([id, raw]) => {
|
|
597
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
598
|
+
throw new Error(`supervisor replanning template ${id} must be an object`);
|
|
599
|
+
}
|
|
600
|
+
const template = raw as Record<string, unknown>;
|
|
601
|
+
if (typeof template.workflow !== "string") {
|
|
602
|
+
throw new Error(`supervisor replanning template ${id} workflow must be a string`);
|
|
603
|
+
}
|
|
604
|
+
return [
|
|
605
|
+
id,
|
|
606
|
+
{
|
|
607
|
+
workflowPath: isAbsolute(template.workflow)
|
|
608
|
+
? template.workflow
|
|
609
|
+
: resolve(base, template.workflow),
|
|
610
|
+
maxAttempts: jsonInteger(template.max_attempts, `template ${id} max_attempts`),
|
|
611
|
+
root: jsonBoolean(template.root, `template ${id} root`),
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
return {
|
|
617
|
+
plannerSpecialist: config.planner_specialist,
|
|
618
|
+
autoApply: jsonBoolean(config.auto_apply, "replanning auto_apply"),
|
|
619
|
+
maxReplans: jsonInteger(config.max_replans, "replanning max_replans"),
|
|
620
|
+
maxWorkItemsPerPlan: jsonInteger(
|
|
621
|
+
config.max_work_items_per_plan,
|
|
622
|
+
"replanning max_work_items_per_plan",
|
|
623
|
+
),
|
|
624
|
+
maxTotalWorkItems: jsonInteger(config.max_total_work_items, "replanning max_total_work_items"),
|
|
625
|
+
templates,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
470
629
|
function emitSupervisor(
|
|
471
630
|
record: SupervisorRecord,
|
|
472
631
|
json: boolean | undefined,
|
|
@@ -486,7 +645,7 @@ function emitSupervisor(
|
|
|
486
645
|
const lines = [
|
|
487
646
|
`${record.intent.id}: ${record.intent.title}`,
|
|
488
647
|
`state: ${projection.state}`,
|
|
489
|
-
`root: ${
|
|
648
|
+
`root: ${projection.root_work_id}`,
|
|
490
649
|
`reason: ${projection.reason}`,
|
|
491
650
|
`next: ${projection.next_action}`,
|
|
492
651
|
`work: ${projection.work_ids.length}`,
|
|
@@ -495,6 +654,15 @@ function emitSupervisor(
|
|
|
495
654
|
`automation: accept=${record.intent.automation.accept_passing_proof}, ` +
|
|
496
655
|
`resume=${record.intent.automation.resume_approved}, retry=${record.intent.automation.retry_blocked}`,
|
|
497
656
|
];
|
|
657
|
+
if (record.intent.replanning) {
|
|
658
|
+
lines.push(
|
|
659
|
+
`replanning: generation=${projection.plan_generation}, used=${projection.replans_used}/${record.intent.replanning.max_replans}, auto_apply=${record.intent.replanning.auto_apply}`,
|
|
660
|
+
);
|
|
661
|
+
if (projection.root_work_id !== record.intent.root_work_id) {
|
|
662
|
+
lines.push(`original root: ${record.intent.root_work_id}`);
|
|
663
|
+
}
|
|
664
|
+
if (projection.pending_plan_id) lines.push(`pending plan: ${projection.pending_plan_id}`);
|
|
665
|
+
}
|
|
498
666
|
if (projection.attention_work.length) {
|
|
499
667
|
lines.push(`attention: ${projection.attention_work.join(", ")}`);
|
|
500
668
|
}
|
|
@@ -515,6 +683,7 @@ function emitSupervisorReport(
|
|
|
515
683
|
`supervisor ${report.goal_id}: ${report.stop_reason}\n` +
|
|
516
684
|
`reason: ${report.reason}\n` +
|
|
517
685
|
`cycles: ${report.cycles}; dispatches: ${report.dispatches}; acceptances: ${report.acceptances}\n` +
|
|
686
|
+
`replans: ${report.replans}\n` +
|
|
518
687
|
`state: ${report.projection.state}; next: ${report.projection.next_action}\n`,
|
|
519
688
|
);
|
|
520
689
|
}
|
|
@@ -531,6 +700,64 @@ function integer(value: string | undefined): number | undefined {
|
|
|
531
700
|
return Number.parseInt(value, 10);
|
|
532
701
|
}
|
|
533
702
|
|
|
703
|
+
function jsonInteger(value: unknown, field: string): number | undefined {
|
|
704
|
+
if (value === undefined) return undefined;
|
|
705
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
706
|
+
throw new Error(`${field} must be a positive integer`);
|
|
707
|
+
}
|
|
708
|
+
return value as number;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function jsonBoolean(value: unknown, field: string): boolean | undefined {
|
|
712
|
+
if (value === undefined) return undefined;
|
|
713
|
+
if (typeof value !== "boolean") throw new Error(`${field} must be boolean`);
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function emitPlan(plan: SupervisorPlanRecord, json: boolean | undefined, emit: EmitContext): void {
|
|
718
|
+
if (json) {
|
|
719
|
+
emit.config({ format: "json" });
|
|
720
|
+
emit.data(plan);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const lines = [
|
|
724
|
+
`${plan.request.id}: ${plan.status}`,
|
|
725
|
+
`goal: ${plan.request.goal_id}`,
|
|
726
|
+
`sequence: ${plan.request.sequence}`,
|
|
727
|
+
`prior root: ${plan.request.prior_root_work_id}`,
|
|
728
|
+
`planner run: ${plan.request.workflow_run_id}`,
|
|
729
|
+
];
|
|
730
|
+
if (plan.proposal) {
|
|
731
|
+
lines.push(`decision: ${plan.proposal.decision}`);
|
|
732
|
+
lines.push(`rationale: ${plan.proposal.rationale}`);
|
|
733
|
+
lines.push(`proposed work: ${plan.proposal.work.length}`);
|
|
734
|
+
}
|
|
735
|
+
if (plan.root_work_id) lines.push(`applied root: ${plan.root_work_id}`);
|
|
736
|
+
if (plan.approval_id) lines.push(`approval: ${plan.approval_id}`);
|
|
737
|
+
emit.text(`${lines.join("\n")}\n`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function emitPlanOutcome(
|
|
741
|
+
outcome: SupervisorPlanOutcome,
|
|
742
|
+
json: boolean | undefined,
|
|
743
|
+
emit: EmitContext,
|
|
744
|
+
): void {
|
|
745
|
+
if (json) {
|
|
746
|
+
emit.config({ format: "json" });
|
|
747
|
+
emit.data(outcome);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
emit.text(
|
|
751
|
+
`supervisor plan ${outcome.plan_id}: ${outcome.status}\n` +
|
|
752
|
+
`${outcome.reason ? `reason: ${outcome.reason}\n` : ""}` +
|
|
753
|
+
`${outcome.root_work_id ? `root: ${outcome.root_work_id}\n` : ""}`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function renderPlanRow(plan: SupervisorPlanRecord): string {
|
|
758
|
+
return `${plan.request.id} ${plan.status.padEnd(18)} ${plan.request.prior_root_work_id}`;
|
|
759
|
+
}
|
|
760
|
+
|
|
534
761
|
function delay(ms: number): Promise<void> {
|
|
535
762
|
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
536
763
|
}
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
export { approveSupervisorPlan, rejectSupervisorPlan } from "./plan-api.ts";
|
|
2
|
+
export { readSupervisorPlan, readSupervisorPlans } from "./plan-read.ts";
|
|
3
|
+
export {
|
|
4
|
+
type CreateSupervisorPlanTemplateInput,
|
|
5
|
+
type CreateSupervisorReplanningInput,
|
|
6
|
+
SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
7
|
+
type SupervisorPlanEvent,
|
|
8
|
+
type SupervisorPlanEventType,
|
|
9
|
+
type SupervisorPlanHistory,
|
|
10
|
+
type SupervisorPlanOutcome,
|
|
11
|
+
type SupervisorPlanProposal,
|
|
12
|
+
type SupervisorPlanRecord,
|
|
13
|
+
type SupervisorPlanRequest,
|
|
14
|
+
type SupervisorPlanStatus,
|
|
15
|
+
type SupervisorPlanTemplate,
|
|
16
|
+
type SupervisorPlanWorkSpec,
|
|
17
|
+
type SupervisorReplanningPolicy,
|
|
18
|
+
} from "./plan-types.ts";
|
|
1
19
|
export {
|
|
2
20
|
type RunSupervisorInput,
|
|
3
21
|
runSupervisor,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SupervisorPlanOutcome } from "./plan-types.ts";
|
|
2
|
+
import { applySupervisorPlanProposal, rejectSupervisorPlanProposal } from "./planning.ts";
|
|
3
|
+
import { acquireSupervisorLease, readSupervisorIgnoringLease } from "./state.ts";
|
|
4
|
+
|
|
5
|
+
export function approveSupervisorPlan(input: {
|
|
6
|
+
coordRoot: string;
|
|
7
|
+
goalId: string;
|
|
8
|
+
planId: string;
|
|
9
|
+
actor?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
}): SupervisorPlanOutcome {
|
|
12
|
+
const actor = input.actor?.trim() || `supervisor:${input.goalId}`;
|
|
13
|
+
const release = acquireSupervisorLease(input.coordRoot, input.goalId);
|
|
14
|
+
try {
|
|
15
|
+
return applySupervisorPlanProposal({
|
|
16
|
+
coordRoot: input.coordRoot,
|
|
17
|
+
record: readSupervisorIgnoringLease(input.coordRoot, input.goalId),
|
|
18
|
+
planId: input.planId,
|
|
19
|
+
actor,
|
|
20
|
+
reason: input.reason,
|
|
21
|
+
});
|
|
22
|
+
} finally {
|
|
23
|
+
release();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function rejectSupervisorPlan(input: {
|
|
28
|
+
coordRoot: string;
|
|
29
|
+
goalId: string;
|
|
30
|
+
planId: string;
|
|
31
|
+
actor?: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
}): SupervisorPlanOutcome {
|
|
34
|
+
const actor = input.actor?.trim() || `supervisor:${input.goalId}`;
|
|
35
|
+
const release = acquireSupervisorLease(input.coordRoot, input.goalId);
|
|
36
|
+
try {
|
|
37
|
+
return rejectSupervisorPlanProposal({
|
|
38
|
+
coordRoot: input.coordRoot,
|
|
39
|
+
goalId: input.goalId,
|
|
40
|
+
planId: input.planId,
|
|
41
|
+
actor,
|
|
42
|
+
reason: input.reason,
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
release();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
SUPERVISOR_PLAN_SCHEMA_VERSION,
|
|
5
|
+
type SupervisorPlanEvent,
|
|
6
|
+
type SupervisorPlanEventType,
|
|
7
|
+
type SupervisorPlanHistory,
|
|
8
|
+
type SupervisorPlanProposal,
|
|
9
|
+
type SupervisorPlanRecord,
|
|
10
|
+
type SupervisorPlanRequest,
|
|
11
|
+
} from "./plan-types.ts";
|
|
12
|
+
|
|
13
|
+
const PLAN_ID = /^plan-[0-9]{4}-[a-f0-9]{8}$/;
|
|
14
|
+
const GOAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
15
|
+
const WORK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
16
|
+
const APPROVAL_ID = /^apr-[a-f0-9]{20}$/;
|
|
17
|
+
const PLAN_KEY = /^[a-z][a-z0-9-]{0,31}$/;
|
|
18
|
+
const TEMPLATE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
19
|
+
const MAX_PLANS = 100;
|
|
20
|
+
const MAX_RECORD_BYTES = 256 * 1024;
|
|
21
|
+
const MAX_EVENTS_BYTES = 512 * 1024;
|
|
22
|
+
const MAX_EVENTS = 100;
|
|
23
|
+
const EVENT_TYPES = new Set<SupervisorPlanEventType>([
|
|
24
|
+
"plan.awaiting_approval",
|
|
25
|
+
"plan.resumed",
|
|
26
|
+
"plan.proposed",
|
|
27
|
+
"plan.applied",
|
|
28
|
+
"plan.rejected",
|
|
29
|
+
"plan.attention",
|
|
30
|
+
"plan.failed",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export function readSupervisorPlans(
|
|
34
|
+
coordRootRaw: string,
|
|
35
|
+
goalId: string,
|
|
36
|
+
originalRootWorkId: string,
|
|
37
|
+
): SupervisorPlanHistory {
|
|
38
|
+
assertId(goalId, GOAL_ID, "supervisor id");
|
|
39
|
+
assertId(originalRootWorkId, WORK_ID, "work id");
|
|
40
|
+
const coordRoot = resolve(coordRootRaw);
|
|
41
|
+
const root = plansRoot(coordRoot, goalId);
|
|
42
|
+
if (!existsSync(root)) {
|
|
43
|
+
return {
|
|
44
|
+
plans: [],
|
|
45
|
+
active_root_work_id: originalRootWorkId,
|
|
46
|
+
generation: 0,
|
|
47
|
+
applied_work_ids: [],
|
|
48
|
+
materialized_work_ids: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
52
|
+
.filter((entry) => entry.isDirectory() && PLAN_ID.test(entry.name))
|
|
53
|
+
.map((entry) => entry.name);
|
|
54
|
+
if (entries.length > MAX_PLANS) throw new Error(`supervisor plan history exceeds ${MAX_PLANS}`);
|
|
55
|
+
const plans = entries
|
|
56
|
+
.map((planId) => readSupervisorPlan(coordRootRaw, goalId, planId))
|
|
57
|
+
.sort((left, right) => left.request.sequence - right.request.sequence);
|
|
58
|
+
const sequences = new Set<number>();
|
|
59
|
+
for (const plan of plans) {
|
|
60
|
+
if (sequences.has(plan.request.sequence)) {
|
|
61
|
+
throw new Error(`supervisor ${goalId} has duplicate plan sequence ${plan.request.sequence}`);
|
|
62
|
+
}
|
|
63
|
+
sequences.add(plan.request.sequence);
|
|
64
|
+
}
|
|
65
|
+
let activeRoot = originalRootWorkId;
|
|
66
|
+
let generation = 0;
|
|
67
|
+
const appliedWork = new Set<string>();
|
|
68
|
+
const materializedWork = new Set<string>();
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
for (const spec of plan.proposal?.work ?? []) {
|
|
71
|
+
const workId = `${plan.request.id}-${spec.key}`;
|
|
72
|
+
if (existsSync(join(coordRoot, ".harnery", "work", workId, "intent.json"))) {
|
|
73
|
+
materializedWork.add(workId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (plan.status !== "applied" || !plan.root_work_id) continue;
|
|
77
|
+
activeRoot = plan.root_work_id;
|
|
78
|
+
generation++;
|
|
79
|
+
for (const workId of plan.work_ids) appliedWork.add(workId);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
plans,
|
|
83
|
+
active_root_work_id: activeRoot,
|
|
84
|
+
generation,
|
|
85
|
+
applied_work_ids: [...appliedWork],
|
|
86
|
+
materialized_work_ids: [...materializedWork],
|
|
87
|
+
latest: plans.at(-1),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readSupervisorPlan(
|
|
92
|
+
coordRootRaw: string,
|
|
93
|
+
goalId: string,
|
|
94
|
+
planId: string,
|
|
95
|
+
): SupervisorPlanRecord {
|
|
96
|
+
assertId(goalId, GOAL_ID, "supervisor id");
|
|
97
|
+
assertId(planId, PLAN_ID, "supervisor plan id");
|
|
98
|
+
const coordRoot = resolve(coordRootRaw);
|
|
99
|
+
const dir = planDir(coordRoot, goalId, planId);
|
|
100
|
+
const request = readJson<SupervisorPlanRequest>(join(dir, "request.json"), "plan request");
|
|
101
|
+
validateRequest(request, goalId, planId);
|
|
102
|
+
const proposalPath = join(dir, "proposal.json");
|
|
103
|
+
const proposal = existsSync(proposalPath)
|
|
104
|
+
? readJson<SupervisorPlanProposal>(proposalPath, "plan proposal")
|
|
105
|
+
: undefined;
|
|
106
|
+
if (proposal) validateProposalEnvelope(proposal, planId);
|
|
107
|
+
const events = readEvents(join(dir, "events.jsonl"), planId);
|
|
108
|
+
const derived = deriveStatus(coordRoot, events, proposal);
|
|
109
|
+
return { request, proposal, events, ...derived };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deriveStatus(
|
|
113
|
+
coordRoot: string,
|
|
114
|
+
events: SupervisorPlanEvent[],
|
|
115
|
+
proposal: SupervisorPlanProposal | undefined,
|
|
116
|
+
): Pick<SupervisorPlanRecord, "status" | "approval_id" | "root_work_id" | "work_ids" | "reason"> {
|
|
117
|
+
const latest = events.at(-1);
|
|
118
|
+
const applied = [...events].reverse().find((event) => event.event === "plan.applied");
|
|
119
|
+
if (applied) {
|
|
120
|
+
return {
|
|
121
|
+
status: "applied",
|
|
122
|
+
root_work_id: applied.root_work_id,
|
|
123
|
+
work_ids: applied.work_ids ?? [],
|
|
124
|
+
reason: applied.reason,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (latest?.event === "plan.rejected") {
|
|
128
|
+
return { status: "rejected", work_ids: [], reason: latest.reason };
|
|
129
|
+
}
|
|
130
|
+
if (latest?.event === "plan.attention") {
|
|
131
|
+
return { status: "attention", work_ids: [], reason: latest.reason };
|
|
132
|
+
}
|
|
133
|
+
if (latest?.event === "plan.failed") {
|
|
134
|
+
return { status: "failed", work_ids: [], reason: latest.reason };
|
|
135
|
+
}
|
|
136
|
+
const parked = [...events].reverse().find((event) => event.event === "plan.awaiting_approval");
|
|
137
|
+
const resumedAfterPark =
|
|
138
|
+
parked !== undefined &&
|
|
139
|
+
events.some((event) => event.event === "plan.resumed" && event.seq > parked.seq);
|
|
140
|
+
if (parked?.approval_id && !resumedAfterPark) {
|
|
141
|
+
return {
|
|
142
|
+
status: approvalIsResolved(coordRoot, parked.approval_id) ? "resumable" : "awaiting_approval",
|
|
143
|
+
approval_id: parked.approval_id,
|
|
144
|
+
work_ids: [],
|
|
145
|
+
reason: parked.reason,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (proposal) return { status: "proposed", work_ids: [], reason: proposal.rationale };
|
|
149
|
+
return { status: "interrupted", work_ids: [], reason: latest?.reason };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readEvents(path: string, planId: string): SupervisorPlanEvent[] {
|
|
153
|
+
if (!existsSync(path)) return [];
|
|
154
|
+
const size = statSync(path).size;
|
|
155
|
+
if (size > MAX_EVENTS_BYTES) throw new Error("supervisor plan events exceed their byte limit");
|
|
156
|
+
const body = readFileSync(path, "utf8");
|
|
157
|
+
if (body && !body.endsWith("\n"))
|
|
158
|
+
throw new Error(`supervisor plan ${planId} has a partial event`);
|
|
159
|
+
const lines = body.split("\n").filter(Boolean);
|
|
160
|
+
if (lines.length > MAX_EVENTS) throw new Error(`supervisor plan exceeds ${MAX_EVENTS} events`);
|
|
161
|
+
return lines.map((line, index) => {
|
|
162
|
+
let event: SupervisorPlanEvent;
|
|
163
|
+
try {
|
|
164
|
+
event = JSON.parse(line) as SupervisorPlanEvent;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`cannot parse supervisor plan ${planId} event ${index + 1}: ${(error as Error).message}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
validateEvent(event, planId, index + 1);
|
|
171
|
+
return event;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateRequest(request: SupervisorPlanRequest, goalId: string, planId: string): void {
|
|
176
|
+
if (
|
|
177
|
+
request.schema_version !== SUPERVISOR_PLAN_SCHEMA_VERSION ||
|
|
178
|
+
request.id !== planId ||
|
|
179
|
+
request.goal_id !== goalId ||
|
|
180
|
+
!Number.isSafeInteger(request.sequence) ||
|
|
181
|
+
request.sequence < 1 ||
|
|
182
|
+
request.sequence > MAX_PLANS ||
|
|
183
|
+
typeof request.trigger_fingerprint !== "string" ||
|
|
184
|
+
request.trigger_fingerprint.length < 1 ||
|
|
185
|
+
request.trigger_fingerprint.length > 64_000 ||
|
|
186
|
+
!WORK_ID.test(request.prior_root_work_id) ||
|
|
187
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(request.workflow_run_id) ||
|
|
188
|
+
!validTimestamp(request.created_at)
|
|
189
|
+
) {
|
|
190
|
+
throw new Error(`supervisor plan request ${planId} has an unsupported schema`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateProposalEnvelope(proposal: SupervisorPlanProposal, planId: string): void {
|
|
195
|
+
if (
|
|
196
|
+
proposal.schema_version !== SUPERVISOR_PLAN_SCHEMA_VERSION ||
|
|
197
|
+
proposal.plan_id !== planId ||
|
|
198
|
+
!["apply", "attention"].includes(proposal.decision) ||
|
|
199
|
+
typeof proposal.rationale !== "string" ||
|
|
200
|
+
proposal.rationale.length < 1 ||
|
|
201
|
+
proposal.rationale.length > 2_000 ||
|
|
202
|
+
!Array.isArray(proposal.work) ||
|
|
203
|
+
proposal.work.length > 25 ||
|
|
204
|
+
typeof proposal.root !== "string" ||
|
|
205
|
+
proposal.root.length > 32 ||
|
|
206
|
+
!validTimestamp(proposal.proposed_at)
|
|
207
|
+
) {
|
|
208
|
+
throw new Error(`supervisor plan proposal ${planId} has an unsupported schema`);
|
|
209
|
+
}
|
|
210
|
+
for (const [index, spec] of proposal.work.entries()) {
|
|
211
|
+
if (
|
|
212
|
+
!spec ||
|
|
213
|
+
typeof spec !== "object" ||
|
|
214
|
+
!PLAN_KEY.test(spec.key) ||
|
|
215
|
+
typeof spec.title !== "string" ||
|
|
216
|
+
spec.title.length < 1 ||
|
|
217
|
+
spec.title.length > 200 ||
|
|
218
|
+
typeof spec.objective !== "string" ||
|
|
219
|
+
spec.objective.length < 1 ||
|
|
220
|
+
spec.objective.length > 4_000 ||
|
|
221
|
+
!Array.isArray(spec.acceptance) ||
|
|
222
|
+
spec.acceptance.length > 50 ||
|
|
223
|
+
spec.acceptance.some((value) => typeof value !== "string" || value.length > 500) ||
|
|
224
|
+
!Array.isArray(spec.dependencies) ||
|
|
225
|
+
spec.dependencies.length > 50 ||
|
|
226
|
+
spec.dependencies.some((value) => typeof value !== "string" || value.length > 100) ||
|
|
227
|
+
!TEMPLATE_ID.test(spec.template)
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(`supervisor plan proposal ${planId} work[${index}] is invalid`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateEvent(event: SupervisorPlanEvent, planId: string, sequence: number): void {
|
|
235
|
+
if (
|
|
236
|
+
event.schema_version !== SUPERVISOR_PLAN_SCHEMA_VERSION ||
|
|
237
|
+
event.plan_id !== planId ||
|
|
238
|
+
event.seq !== sequence ||
|
|
239
|
+
!EVENT_TYPES.has(event.event) ||
|
|
240
|
+
!validTimestamp(event.ts) ||
|
|
241
|
+
typeof event.actor !== "string" ||
|
|
242
|
+
event.actor.length < 1 ||
|
|
243
|
+
event.actor.length > 200 ||
|
|
244
|
+
typeof event.reason !== "string" ||
|
|
245
|
+
event.reason.length < 1 ||
|
|
246
|
+
event.reason.length > 2_000
|
|
247
|
+
) {
|
|
248
|
+
throw new Error(`supervisor plan ${planId} event ${sequence} has an unsupported schema`);
|
|
249
|
+
}
|
|
250
|
+
if (event.approval_id !== undefined && !APPROVAL_ID.test(event.approval_id)) {
|
|
251
|
+
throw new Error(`supervisor plan ${planId} event ${sequence} has an invalid approval id`);
|
|
252
|
+
}
|
|
253
|
+
if (event.root_work_id !== undefined && !WORK_ID.test(event.root_work_id)) {
|
|
254
|
+
throw new Error(`supervisor plan ${planId} event ${sequence} has an invalid root work id`);
|
|
255
|
+
}
|
|
256
|
+
if (event.work_ids !== undefined) {
|
|
257
|
+
if (!Array.isArray(event.work_ids) || event.work_ids.some((id) => !WORK_ID.test(id))) {
|
|
258
|
+
throw new Error(`supervisor plan ${planId} event ${sequence} has invalid work ids`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function approvalIsResolved(coordRoot: string, approvalId: string): boolean {
|
|
264
|
+
const path = join(coordRoot, ".harnery", "approvals", approvalId, "decision.json");
|
|
265
|
+
if (!existsSync(path)) return false;
|
|
266
|
+
try {
|
|
267
|
+
const value = readJson<Record<string, unknown>>(path, "workflow approval decision");
|
|
268
|
+
return value.approval_id === approvalId && ["allow", "deny"].includes(String(value.verdict));
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readJson<T>(path: string, label: string): T {
|
|
275
|
+
if (!existsSync(path)) throw new Error(`${label} does not exist at ${path}`);
|
|
276
|
+
const size = statSync(path).size;
|
|
277
|
+
if (size <= 0 || size > MAX_RECORD_BYTES) throw new Error(`${label} has invalid size ${size}`);
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new Error(`cannot parse ${label} at ${path}: ${(error as Error).message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function plansRoot(coordRoot: string, goalId: string): string {
|
|
286
|
+
return join(coordRoot, ".harnery", "supervisors", goalId, "plans");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function planDir(coordRoot: string, goalId: string, planId: string): string {
|
|
290
|
+
return join(plansRoot(coordRoot, goalId), planId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function assertId(value: string, pattern: RegExp, label: string): void {
|
|
294
|
+
if (!pattern.test(value)) throw new Error(`invalid ${label} ${JSON.stringify(value)}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validTimestamp(value: unknown): value is string {
|
|
298
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
299
|
+
}
|