pi-crew 0.9.65 → 0.9.67
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/CHANGELOG.md +79 -0
- package/README.md +2 -3
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/dist/index.mjs +4526 -4100
- package/package.json +7 -4
- package/skills/real-test-pi-crew/SKILL.md +10 -9
- package/src/config/config.ts +19 -3
- package/src/config/role-tools.ts +2 -2
- package/src/config/types.ts +2 -0
- package/src/extension/knowledge-injection.ts +17 -0
- package/src/extension/pi-api.ts +0 -16
- package/src/extension/team-tool/api/agent-control.ts +358 -0
- package/src/extension/team-tool/api/handler-context.ts +57 -0
- package/src/extension/team-tool/api/heartbeat.ts +75 -0
- package/src/extension/team-tool/api/mailbox.ts +242 -0
- package/src/extension/team-tool/api/plan-approval.ts +190 -0
- package/src/extension/team-tool/api/read.ts +443 -0
- package/src/extension/team-tool/api/task-claims.ts +207 -0
- package/src/extension/team-tool/api.ts +56 -1301
- package/src/extension/team-tool/cancel.ts +49 -4
- package/src/extension/team-tool/dispatch/manage.ts +7 -4
- package/src/extension/team-tool/explain.ts +3 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +4 -4
- package/src/extension/team-tool/lifecycle-actions.ts +9 -6
- package/src/extension/team-tool/parallel-dispatch.ts +2 -2
- package/src/extension/team-tool/respond.ts +11 -3
- package/src/extension/team-tool/run-intent.ts +357 -0
- package/src/extension/team-tool/run.ts +8 -285
- package/src/extension/team-tool/status.ts +56 -21
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +10 -8
- package/src/prompt/scratchpad-lifecycle.ts +80 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
- package/src/runtime/child-pi/child-pi.ts +22 -144
- package/src/runtime/child-pi/mock-fixtures.ts +171 -0
- package/src/runtime/crew-agent-records.ts +18 -1
- package/src/runtime/output/output-validator.ts +34 -6
- package/src/runtime/scheduling/scheduler.ts +67 -19
- package/src/runtime/scratchpad/README.md +6 -0
- package/src/runtime/scratchpad/guest.ts +54 -5
- package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
- package/src/runtime/supervisor-contact.ts +0 -16
- package/src/runtime/task-runner/child-executor.ts +1 -0
- package/src/runtime/task-runner/state-helpers.ts +9 -1
- package/src/runtime/team-runner.ts +39 -2
- package/src/state/contracts.ts +3 -0
- package/src/state/event-log/event-log.ts +19 -27
- package/src/state/gitignore-manager.ts +61 -7
- package/src/utils/glob-match.ts +29 -0
- package/types/dwf.d.ts +1 -1
- package/src/types/new-api-types.ts +0 -35
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared context for extracted `api` operation handlers (H3 phase 1+).
|
|
3
|
+
*
|
|
4
|
+
* The `handleApi` function in `src/extension/team-tool/api.ts` was 1222 lines
|
|
5
|
+
* with 35+ `if (operation === "…")` branches. This file defines the shared
|
|
6
|
+
* bundle of closure dependencies that each extracted handler receives, so
|
|
7
|
+
* sibling modules under `src/extension/team-tool/api/<group>.ts` can be
|
|
8
|
+
* pure-function-shaped without re-establishing the closure.
|
|
9
|
+
*
|
|
10
|
+
* Extraction pattern mirrors `merge-gate.ts` (improvement-plan-2026-08-09
|
|
11
|
+
* phase 5): the extracted module imports its own state/coordination helpers
|
|
12
|
+
* directly; only the request-scoped values (config, loaded run, params,
|
|
13
|
+
* extension context) are passed via this context.
|
|
14
|
+
*
|
|
15
|
+
* Phase 2 (2026-08-10): added `params` + `ctx` and made handlers async-capable
|
|
16
|
+
* (several operations await live-agent or runtime resolution).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { TeamToolParamsValue } from "../../../schema/team-tool-schema.ts";
|
|
20
|
+
import type { TeamRunManifest, TeamTaskState } from "../../../state/types.ts";
|
|
21
|
+
import type { PiTeamsToolResult } from "../../tool-result.ts";
|
|
22
|
+
import type { result as resultFn, TeamContext } from "../context.ts";
|
|
23
|
+
import type { paramRequired as paramRequiredFn } from "../param-error.ts";
|
|
24
|
+
|
|
25
|
+
/** The config record produced by `configRecord(params.config)` in handleApi. */
|
|
26
|
+
export type ApiConfig = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
/** The loaded run snapshot returned by `loadRunManifestById`. */
|
|
29
|
+
export interface ApiLoadedRun {
|
|
30
|
+
manifest: TeamRunManifest;
|
|
31
|
+
tasks: TeamTaskState[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bundle of closure-scoped dependencies that handleApi establishes once and
|
|
36
|
+
* passes to each extracted handler. Adding a field here is cheaper than
|
|
37
|
+
* growing the closure of a 1200-line function.
|
|
38
|
+
*/
|
|
39
|
+
export interface ApiHandlerContext {
|
|
40
|
+
cfg: ApiConfig;
|
|
41
|
+
loaded: ApiLoadedRun;
|
|
42
|
+
result: typeof resultFn;
|
|
43
|
+
paramRequired: typeof paramRequiredFn;
|
|
44
|
+
/** The raw tool params (runId, force, etc.) the caller passed. */
|
|
45
|
+
params: TeamToolParamsValue;
|
|
46
|
+
/** The extension TeamContext (cwd, events, metricRegistry, config…). */
|
|
47
|
+
ctx: TeamContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Type alias for an extracted operation handler. Async-capable. */
|
|
51
|
+
export type ApiOperationHandler = (ctx: ApiHandlerContext) => PiTeamsToolResult | Promise<PiTeamsToolResult>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handler for operations that run BEFORE the runId guard (no loaded run).
|
|
55
|
+
* Currently: metrics-snapshot, inventory.
|
|
56
|
+
*/
|
|
57
|
+
export type ApiPreHandler = (ctx: Omit<ApiHandlerContext, "loaded">) => PiTeamsToolResult | Promise<PiTeamsToolResult>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted `api` operation handlers — heartbeat group (H3 phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/extension/team-tool/api.ts` on 2026-08-10. Behaviour is
|
|
5
|
+
* byte-identical to the inline `if` blocks they replace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { touchWorkerHeartbeat } from "../../../runtime/heartbeat/worker-heartbeat.ts";
|
|
9
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
10
|
+
import { appendEvent } from "../../../state/event-log/event-log.ts";
|
|
11
|
+
import { saveRunTasks } from "../../../state/stores/state-store.ts";
|
|
12
|
+
import type { ApiOperationHandler } from "./handler-context.ts";
|
|
13
|
+
|
|
14
|
+
export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
|
|
15
|
+
const { cfg, loaded, result, paramRequired } = hctx;
|
|
16
|
+
const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
|
|
17
|
+
const task = loaded.tasks.find((item) => item.id === taskId || item.stepId === taskId);
|
|
18
|
+
if (!task)
|
|
19
|
+
return result(
|
|
20
|
+
paramRequired(
|
|
21
|
+
"API write-heartbeat",
|
|
22
|
+
"config.taskId matching a task id or step id",
|
|
23
|
+
"{ action: 'api', runId: 'team_...', config: { operation: 'write-heartbeat', taskId: '01_01-agent' } }",
|
|
24
|
+
),
|
|
25
|
+
{
|
|
26
|
+
action: "api",
|
|
27
|
+
status: "error",
|
|
28
|
+
runId: loaded.manifest.runId,
|
|
29
|
+
},
|
|
30
|
+
true,
|
|
31
|
+
);
|
|
32
|
+
try {
|
|
33
|
+
return withRunLockSync(loaded.manifest, () => {
|
|
34
|
+
const heartbeat = touchWorkerHeartbeat(
|
|
35
|
+
task.heartbeat ?? {
|
|
36
|
+
workerId: task.id,
|
|
37
|
+
lastSeenAt: new Date().toISOString(),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
alive: typeof cfg.alive === "boolean" ? cfg.alive : undefined,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
const tasks = loaded.tasks.map((item) => (item.id === task.id ? { ...item, heartbeat } : item));
|
|
44
|
+
saveRunTasks(loaded.manifest, tasks);
|
|
45
|
+
appendEvent(loaded.manifest.eventsPath, {
|
|
46
|
+
type: "worker.heartbeat",
|
|
47
|
+
runId: loaded.manifest.runId,
|
|
48
|
+
taskId: task.id,
|
|
49
|
+
data: { ...heartbeat },
|
|
50
|
+
});
|
|
51
|
+
return result(JSON.stringify(heartbeat, null, 2), {
|
|
52
|
+
action: "api",
|
|
53
|
+
status: "ok",
|
|
54
|
+
runId: loaded.manifest.runId,
|
|
55
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
return result(
|
|
61
|
+
message,
|
|
62
|
+
{
|
|
63
|
+
action: "api",
|
|
64
|
+
status: "error",
|
|
65
|
+
runId: loaded.manifest.runId,
|
|
66
|
+
},
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Dispatcher map for the heartbeat group. Consumed by handleApi. */
|
|
73
|
+
export const HEARTBEAT_OPERATIONS: Record<string, ApiOperationHandler> = {
|
|
74
|
+
"write-heartbeat": handleWriteHeartbeat,
|
|
75
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted `api` operation handlers — mailbox group (H3 phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/extension/team-tool/api.ts` on 2026-08-10. Behaviour is
|
|
5
|
+
* byte-identical to the inline `if` blocks they replace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
9
|
+
import {
|
|
10
|
+
acknowledgeMailboxMessage,
|
|
11
|
+
appendMailboxMessage,
|
|
12
|
+
type MailboxDirection,
|
|
13
|
+
type MailboxMessageKind,
|
|
14
|
+
readDeliveryState,
|
|
15
|
+
readMailbox,
|
|
16
|
+
readMailboxMessage,
|
|
17
|
+
validateMailbox,
|
|
18
|
+
} from "../../../state/coordination/mailbox.ts";
|
|
19
|
+
import { appendEvent } from "../../../state/event-log/event-log.ts";
|
|
20
|
+
import type { ApiOperationHandler } from "./handler-context.ts";
|
|
21
|
+
|
|
22
|
+
export const handleReadMailbox: ApiOperationHandler = (hctx) => {
|
|
23
|
+
const { cfg, loaded, result } = hctx;
|
|
24
|
+
const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? (cfg.direction as MailboxDirection) : undefined;
|
|
25
|
+
const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
|
|
26
|
+
const kind =
|
|
27
|
+
typeof cfg.kind === "string" && ["message", "steer", "follow-up", "response", "group_join"].includes(cfg.kind)
|
|
28
|
+
? (cfg.kind as MailboxMessageKind)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (taskId && !loaded.tasks.some((task) => task.id === taskId))
|
|
31
|
+
return result(
|
|
32
|
+
`API read-mailbox taskId '${taskId}' does not match a run task.`,
|
|
33
|
+
{
|
|
34
|
+
action: "api",
|
|
35
|
+
status: "error",
|
|
36
|
+
runId: loaded.manifest.runId,
|
|
37
|
+
},
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
try {
|
|
41
|
+
return result(JSON.stringify(readMailbox(loaded.manifest, direction, taskId, kind), null, 2), {
|
|
42
|
+
action: "api",
|
|
43
|
+
status: "ok",
|
|
44
|
+
runId: loaded.manifest.runId,
|
|
45
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
49
|
+
return result(
|
|
50
|
+
message,
|
|
51
|
+
{
|
|
52
|
+
action: "api",
|
|
53
|
+
status: "error",
|
|
54
|
+
runId: loaded.manifest.runId,
|
|
55
|
+
},
|
|
56
|
+
true,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const handleValidateMailbox: ApiOperationHandler = (hctx) => {
|
|
62
|
+
const { cfg, loaded, result } = hctx;
|
|
63
|
+
const report = validateMailbox(loaded.manifest, {
|
|
64
|
+
repair: cfg.repair === true,
|
|
65
|
+
});
|
|
66
|
+
return result(
|
|
67
|
+
JSON.stringify(report, null, 2),
|
|
68
|
+
{
|
|
69
|
+
action: "api",
|
|
70
|
+
status: report.issues.some((issue) => issue.level === "error") && cfg.repair !== true ? "error" : "ok",
|
|
71
|
+
runId: loaded.manifest.runId,
|
|
72
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
73
|
+
},
|
|
74
|
+
report.issues.some((issue) => issue.level === "error") && cfg.repair !== true,
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const handleReadDelivery: ApiOperationHandler = (hctx) => {
|
|
79
|
+
const { loaded, result } = hctx;
|
|
80
|
+
return result(JSON.stringify(readDeliveryState(loaded.manifest), null, 2), {
|
|
81
|
+
action: "api",
|
|
82
|
+
status: "ok",
|
|
83
|
+
runId: loaded.manifest.runId,
|
|
84
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const handleSendMessage: ApiOperationHandler = (hctx) => {
|
|
89
|
+
const { cfg, loaded, result, paramRequired, ctx } = hctx;
|
|
90
|
+
const direction = cfg.direction === "outbox" ? "outbox" : "inbox";
|
|
91
|
+
const from = typeof cfg.from === "string" && cfg.from.trim() ? cfg.from.trim() : "api";
|
|
92
|
+
const to = typeof cfg.to === "string" && cfg.to.trim() ? cfg.to.trim() : "leader";
|
|
93
|
+
const body = typeof cfg.body === "string" && cfg.body.trim() ? cfg.body : undefined;
|
|
94
|
+
const taskId = typeof cfg.taskId === "string" && cfg.taskId.trim() ? cfg.taskId.trim() : undefined;
|
|
95
|
+
if (!body)
|
|
96
|
+
return result(
|
|
97
|
+
paramRequired(
|
|
98
|
+
"API send-message",
|
|
99
|
+
"config.body",
|
|
100
|
+
"{ action: 'api', runId: 'team_...', config: { operation: 'send-message', body: '<message>' } }",
|
|
101
|
+
),
|
|
102
|
+
{
|
|
103
|
+
action: "api",
|
|
104
|
+
status: "error",
|
|
105
|
+
runId: loaded.manifest.runId,
|
|
106
|
+
},
|
|
107
|
+
true,
|
|
108
|
+
);
|
|
109
|
+
if (taskId && !loaded.tasks.some((task) => task.id === taskId))
|
|
110
|
+
return result(
|
|
111
|
+
`API send-message taskId '${taskId}' does not match a run task.`,
|
|
112
|
+
{
|
|
113
|
+
action: "api",
|
|
114
|
+
status: "error",
|
|
115
|
+
runId: loaded.manifest.runId,
|
|
116
|
+
},
|
|
117
|
+
true,
|
|
118
|
+
);
|
|
119
|
+
try {
|
|
120
|
+
return withRunLockSync(loaded.manifest, () => {
|
|
121
|
+
const message = appendMailboxMessage(loaded.manifest, {
|
|
122
|
+
direction,
|
|
123
|
+
from,
|
|
124
|
+
to,
|
|
125
|
+
body,
|
|
126
|
+
taskId,
|
|
127
|
+
});
|
|
128
|
+
// H1 (2026-08-10): informational mailbox event inside a SYNC
|
|
129
|
+
// run-lock callback. Sync append (byte-identical to pre-extract
|
|
130
|
+
// api.ts) so consumers reading eventsPath immediately after the
|
|
131
|
+
// call see the event — async fire-and-forget would race.
|
|
132
|
+
appendEvent(loaded.manifest.eventsPath, {
|
|
133
|
+
type: "mailbox.message",
|
|
134
|
+
runId: loaded.manifest.runId,
|
|
135
|
+
data: { id: message.id, direction, from, to },
|
|
136
|
+
});
|
|
137
|
+
ctx.events?.emit?.("crew.mailbox.message", {
|
|
138
|
+
runId: loaded.manifest.runId,
|
|
139
|
+
id: message.id,
|
|
140
|
+
direction,
|
|
141
|
+
from,
|
|
142
|
+
to,
|
|
143
|
+
taskId,
|
|
144
|
+
source: "send-message",
|
|
145
|
+
});
|
|
146
|
+
return result(JSON.stringify(message, null, 2), {
|
|
147
|
+
action: "api",
|
|
148
|
+
status: "ok",
|
|
149
|
+
runId: loaded.manifest.runId,
|
|
150
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
return result(
|
|
156
|
+
message,
|
|
157
|
+
{
|
|
158
|
+
action: "api",
|
|
159
|
+
status: "error",
|
|
160
|
+
runId: loaded.manifest.runId,
|
|
161
|
+
},
|
|
162
|
+
true,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const handleAckMessage: ApiOperationHandler = (hctx) => {
|
|
168
|
+
const { cfg, loaded, result, paramRequired, ctx } = hctx;
|
|
169
|
+
const messageId = typeof cfg.messageId === "string" ? cfg.messageId : undefined;
|
|
170
|
+
if (!messageId)
|
|
171
|
+
return result(
|
|
172
|
+
paramRequired(
|
|
173
|
+
"API ack-message",
|
|
174
|
+
"config.messageId",
|
|
175
|
+
"{ action: 'api', runId: 'team_...', config: { operation: 'ack-message', messageId: 'msg-1' } }",
|
|
176
|
+
),
|
|
177
|
+
{
|
|
178
|
+
action: "api",
|
|
179
|
+
status: "error",
|
|
180
|
+
runId: loaded.manifest.runId,
|
|
181
|
+
},
|
|
182
|
+
true,
|
|
183
|
+
);
|
|
184
|
+
try {
|
|
185
|
+
return withRunLockSync(loaded.manifest, () => {
|
|
186
|
+
const message = readMailboxMessage(loaded.manifest, messageId);
|
|
187
|
+
const delivery = acknowledgeMailboxMessage(loaded.manifest, messageId);
|
|
188
|
+
appendEvent(loaded.manifest.eventsPath, {
|
|
189
|
+
type: "mailbox.acknowledged",
|
|
190
|
+
runId: loaded.manifest.runId,
|
|
191
|
+
data: { messageId },
|
|
192
|
+
});
|
|
193
|
+
if (message?.data?.kind === "group_join" && typeof message.data.requestId === "string") {
|
|
194
|
+
appendEvent(loaded.manifest.eventsPath, {
|
|
195
|
+
type: "agent.group_join.acknowledged",
|
|
196
|
+
runId: loaded.manifest.runId,
|
|
197
|
+
message: "Group join delivery acknowledged via mailbox ack.",
|
|
198
|
+
data: {
|
|
199
|
+
requestId: message.data.requestId,
|
|
200
|
+
messageId,
|
|
201
|
+
batchId: message.data.batchId,
|
|
202
|
+
partial: message.data.partial,
|
|
203
|
+
acknowledgedAt: delivery.updatedAt,
|
|
204
|
+
acknowledgedBy: "leader",
|
|
205
|
+
},
|
|
206
|
+
metadata: { provenance: "api" },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
ctx.events?.emit?.("crew.mailbox.acknowledged", {
|
|
210
|
+
runId: loaded.manifest.runId,
|
|
211
|
+
messageId,
|
|
212
|
+
delivery,
|
|
213
|
+
});
|
|
214
|
+
return result(JSON.stringify(delivery, null, 2), {
|
|
215
|
+
action: "api",
|
|
216
|
+
status: "ok",
|
|
217
|
+
runId: loaded.manifest.runId,
|
|
218
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
return result(
|
|
224
|
+
message,
|
|
225
|
+
{
|
|
226
|
+
action: "api",
|
|
227
|
+
status: "error",
|
|
228
|
+
runId: loaded.manifest.runId,
|
|
229
|
+
},
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Dispatcher map for the mailbox group. Consumed by handleApi. */
|
|
236
|
+
export const MAILBOX_OPERATIONS: Record<string, ApiOperationHandler> = {
|
|
237
|
+
"read-mailbox": handleReadMailbox,
|
|
238
|
+
"validate-mailbox": handleValidateMailbox,
|
|
239
|
+
"read-delivery": handleReadDelivery,
|
|
240
|
+
"send-message": handleSendMessage,
|
|
241
|
+
"ack-message": handleAckMessage,
|
|
242
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted `api` operation handlers — plan approval group (H3 phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/extension/team-tool/api.ts` on 2026-08-10. Behaviour is
|
|
5
|
+
* byte-identical to the inline `if` blocks they replace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { terminateLiveAgentsForRun } from "../../../runtime/live-session/live-agent-manager.ts";
|
|
9
|
+
import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
|
|
10
|
+
import { withRunLock } from "../../../state/coordination/locks.ts";
|
|
11
|
+
import { appendEvent } from "../../../state/event-log/event-log.ts";
|
|
12
|
+
import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../../../state/stores/state-store.ts";
|
|
13
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
14
|
+
import type { ApiOperationHandler } from "./handler-context.ts";
|
|
15
|
+
|
|
16
|
+
function canApprovePlan(): { allowed: boolean; reason?: string } {
|
|
17
|
+
const role = currentCrewRole();
|
|
18
|
+
if (!role) return { allowed: true };
|
|
19
|
+
if (permissionForRole(role) === "read_only")
|
|
20
|
+
return {
|
|
21
|
+
allowed: false,
|
|
22
|
+
reason: `Role '${role}' is read-only and cannot approve or cancel plan gates.`,
|
|
23
|
+
};
|
|
24
|
+
return { allowed: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const handleApprovePlan: ApiOperationHandler = async (hctx) => {
|
|
28
|
+
const { loaded, result, ctx } = hctx;
|
|
29
|
+
const permission = canApprovePlan();
|
|
30
|
+
if (!permission.allowed)
|
|
31
|
+
return result(
|
|
32
|
+
permission.reason ?? "Plan approval is not allowed in this context.",
|
|
33
|
+
{
|
|
34
|
+
action: "api",
|
|
35
|
+
status: "error",
|
|
36
|
+
runId: loaded.manifest.runId,
|
|
37
|
+
},
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
try {
|
|
41
|
+
return await withRunLock(loaded.manifest, async () => {
|
|
42
|
+
const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLock - consistent read
|
|
43
|
+
const approval = current.manifest.planApproval;
|
|
44
|
+
if (!approval?.required || approval.status !== "pending")
|
|
45
|
+
return result(
|
|
46
|
+
"Run has no pending plan approval request.",
|
|
47
|
+
{
|
|
48
|
+
action: "api",
|
|
49
|
+
status: "error",
|
|
50
|
+
runId: loaded.manifest.runId,
|
|
51
|
+
},
|
|
52
|
+
true,
|
|
53
|
+
);
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
const manifest = {
|
|
56
|
+
...current.manifest,
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
planApproval: {
|
|
59
|
+
...approval,
|
|
60
|
+
status: "approved" as const,
|
|
61
|
+
approvedAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
await saveRunManifestAsync(manifest);
|
|
66
|
+
appendEvent(manifest.eventsPath, {
|
|
67
|
+
type: "plan.approved",
|
|
68
|
+
runId: manifest.runId,
|
|
69
|
+
taskId: approval.planTaskId,
|
|
70
|
+
message: "Adaptive implementation plan approved; resume the run to execute mutating tasks.",
|
|
71
|
+
metadata: { provenance: "api" },
|
|
72
|
+
});
|
|
73
|
+
return result(JSON.stringify(manifest.planApproval, null, 2), {
|
|
74
|
+
action: "api",
|
|
75
|
+
status: "ok",
|
|
76
|
+
runId: manifest.runId,
|
|
77
|
+
artifactsRoot: manifest.artifactsRoot,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
return result(
|
|
83
|
+
message,
|
|
84
|
+
{
|
|
85
|
+
action: "api",
|
|
86
|
+
status: "error",
|
|
87
|
+
runId: loaded.manifest.runId,
|
|
88
|
+
},
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const handleCancelPlan: ApiOperationHandler = async (hctx) => {
|
|
95
|
+
const { loaded, result, ctx } = hctx;
|
|
96
|
+
const permission = canApprovePlan();
|
|
97
|
+
if (!permission.allowed)
|
|
98
|
+
return result(
|
|
99
|
+
permission.reason ?? "Plan approval cancellation is not allowed in this context.",
|
|
100
|
+
{
|
|
101
|
+
action: "api",
|
|
102
|
+
status: "error",
|
|
103
|
+
runId: loaded.manifest.runId,
|
|
104
|
+
},
|
|
105
|
+
true,
|
|
106
|
+
);
|
|
107
|
+
try {
|
|
108
|
+
return await withRunLock(loaded.manifest, async () => {
|
|
109
|
+
const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLock - consistent read
|
|
110
|
+
const approval = current.manifest.planApproval;
|
|
111
|
+
if (!approval?.required || approval.status !== "pending")
|
|
112
|
+
return result(
|
|
113
|
+
"Run has no pending plan approval request.",
|
|
114
|
+
{
|
|
115
|
+
action: "api",
|
|
116
|
+
status: "error",
|
|
117
|
+
runId: loaded.manifest.runId,
|
|
118
|
+
},
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
const tasks = current.tasks.map((task) =>
|
|
123
|
+
task.status === "queued" || task.status === "running" || task.status === "waiting"
|
|
124
|
+
? {
|
|
125
|
+
...task,
|
|
126
|
+
status: "cancelled" as const,
|
|
127
|
+
finishedAt: now,
|
|
128
|
+
error: "Plan approval was cancelled.",
|
|
129
|
+
}
|
|
130
|
+
: task,
|
|
131
|
+
);
|
|
132
|
+
let manifest: typeof current.manifest = {
|
|
133
|
+
...current.manifest,
|
|
134
|
+
updatedAt: now,
|
|
135
|
+
planApproval: {
|
|
136
|
+
...approval,
|
|
137
|
+
status: "cancelled" as const,
|
|
138
|
+
cancelledAt: now,
|
|
139
|
+
updatedAt: now,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
await saveRunManifestAsync(manifest);
|
|
143
|
+
saveRunTasks(manifest, tasks);
|
|
144
|
+
appendEvent(manifest.eventsPath, {
|
|
145
|
+
type: "plan.cancelled",
|
|
146
|
+
runId: manifest.runId,
|
|
147
|
+
taskId: approval.planTaskId,
|
|
148
|
+
message: "Adaptive implementation plan was cancelled.",
|
|
149
|
+
metadata: { provenance: "api" },
|
|
150
|
+
});
|
|
151
|
+
manifest = updateRunStatus(manifest, "cancelled", "Plan approval was cancelled.");
|
|
152
|
+
void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, manifest.eventsPath).catch((error) =>
|
|
153
|
+
logInternalError("team-tool.cancel-plan.terminate", error, `runId=${manifest.runId}`),
|
|
154
|
+
);
|
|
155
|
+
return result(
|
|
156
|
+
JSON.stringify(
|
|
157
|
+
{
|
|
158
|
+
planApproval: manifest.planApproval,
|
|
159
|
+
cancelledTasks: tasks.filter((task) => task.status === "cancelled").map((task) => task.id),
|
|
160
|
+
},
|
|
161
|
+
null,
|
|
162
|
+
2,
|
|
163
|
+
),
|
|
164
|
+
{
|
|
165
|
+
action: "api",
|
|
166
|
+
status: "ok",
|
|
167
|
+
runId: manifest.runId,
|
|
168
|
+
artifactsRoot: manifest.artifactsRoot,
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
return result(
|
|
175
|
+
message,
|
|
176
|
+
{
|
|
177
|
+
action: "api",
|
|
178
|
+
status: "error",
|
|
179
|
+
runId: loaded.manifest.runId,
|
|
180
|
+
},
|
|
181
|
+
true,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Dispatcher map for the plan-approval group. Consumed by handleApi. */
|
|
187
|
+
export const PLAN_APPROVAL_OPERATIONS: Record<string, ApiOperationHandler> = {
|
|
188
|
+
"approve-plan": handleApprovePlan,
|
|
189
|
+
"cancel-plan": handleCancelPlan,
|
|
190
|
+
};
|