@brainervirus/workit-core 5.0.0 → 6.0.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/package.json +1 -1
- package/src/autonomy.ts +309 -44
- package/src/core/context-read.ts +144 -0
- package/src/core/methods.ts +7 -4
- package/src/core/store-lock.ts +1 -1
- package/src/core/task-context.ts +1 -1
- package/src/core/task-contract.ts +8 -130
- package/src/core/task-engine.ts +57 -879
- package/src/core/task-evaluation.ts +63 -5
- package/src/core/task-store.ts +2 -107
- package/src/core/workers.ts +12 -158
- package/src/core/workspaces.ts +15 -0
- package/src/core/youtrack-writes.ts +92 -0
- package/src/core.ts +2 -79
- package/src/forge/pr-ops.ts +2 -2
- package/src/hooks/handle.ts +1 -1
- package/src/hooks/hosts/claude-code.ts +0 -8
- package/src/hooks/hosts/codex.ts +2 -11
- package/src/hooks/hosts/cursor.ts +0 -8
- package/src/hooks/hosts/opencode.ts +0 -8
- package/src/hooks/hosts/pi.ts +0 -10
- package/templates/workit-contract.md +3 -2
- package/src/core/authority.ts +0 -1033
- package/src/core/auto-approval.ts +0 -338
- package/src/core/external-action-effects.ts +0 -2441
- package/src/core/external-action.ts +0 -1204
|
@@ -1,1204 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
canonicalJson,
|
|
3
|
-
failure,
|
|
4
|
-
success,
|
|
5
|
-
type Id,
|
|
6
|
-
type Ref,
|
|
7
|
-
type Result,
|
|
8
|
-
type Revision,
|
|
9
|
-
type Decision,
|
|
10
|
-
type Entry,
|
|
11
|
-
type TaskRecord,
|
|
12
|
-
refSchema,
|
|
13
|
-
sha256,
|
|
14
|
-
} from "./task-contract";
|
|
15
|
-
import { WorkitCore } from "./task-engine";
|
|
16
|
-
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
17
|
-
import {
|
|
18
|
-
chainStepKey,
|
|
19
|
-
normalizeChainSteps,
|
|
20
|
-
type ActionReservation,
|
|
21
|
-
type ChainStep,
|
|
22
|
-
} from "./authority";
|
|
23
|
-
import * as z from "zod";
|
|
24
|
-
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { realpathSync } from "node:fs";
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
|
|
28
|
-
const chainStepSchema = z.union([
|
|
29
|
-
z.string().min(1),
|
|
30
|
-
z.object({ branch: z.string().min(1) }).strict(),
|
|
31
|
-
z.object({ pr: z.literal(true) }).strict(),
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
export type AuthorizedActionInput = {
|
|
35
|
-
core: WorkitCore;
|
|
36
|
-
taskId: Id;
|
|
37
|
-
decisionId: Id;
|
|
38
|
-
actionRef: Ref;
|
|
39
|
-
expectedRevision: Revision;
|
|
40
|
-
expectedWorkspaceRevision: Revision;
|
|
41
|
-
binding?: Decision["binding"];
|
|
42
|
-
reserveObservation: unknown;
|
|
43
|
-
settleObservation: (
|
|
44
|
-
outcome: "succeeded" | "not_started" | "unknown",
|
|
45
|
-
revisions?: { taskRevision: Revision; workspaceRevision: Revision },
|
|
46
|
-
) => unknown;
|
|
47
|
-
refresh?: () => Pick<AuthorizedActionInput, "expectedRevision" | "expectedWorkspaceRevision">;
|
|
48
|
-
step?: string;
|
|
49
|
-
capability?: string;
|
|
50
|
-
available?: boolean;
|
|
51
|
-
failureOutcome?: "not_started" | "unknown";
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export type ExternalActionResult<T> = Result<T>;
|
|
55
|
-
type WithExternalActionLock = <T>(
|
|
56
|
-
operation: () => Promise<ExternalActionResult<T>>,
|
|
57
|
-
) => Promise<ExternalActionResult<T>>;
|
|
58
|
-
|
|
59
|
-
export const externalActionSchema = z.discriminatedUnion("operation", [
|
|
60
|
-
z
|
|
61
|
-
.object({
|
|
62
|
-
operation: z.literal("git.branch_setup"),
|
|
63
|
-
payload: z
|
|
64
|
-
.object({
|
|
65
|
-
action: z.enum(["setup", "reapply_stash"]).optional(),
|
|
66
|
-
sdd_dir: z.string().optional(),
|
|
67
|
-
target_branch: z.string().optional(),
|
|
68
|
-
stash: z.enum(["yes", "no"]).optional(),
|
|
69
|
-
cwd: z.string().min(1).optional(),
|
|
70
|
-
})
|
|
71
|
-
.strict(),
|
|
72
|
-
})
|
|
73
|
-
.strict(),
|
|
74
|
-
z
|
|
75
|
-
.object({
|
|
76
|
-
operation: z.literal("git.commit"),
|
|
77
|
-
payload: z
|
|
78
|
-
.object({
|
|
79
|
-
message: z.string().min(1).optional(),
|
|
80
|
-
plan_steps: z.array(chainStepSchema).min(1).max(32).optional(),
|
|
81
|
-
plan_branch: z.string().min(1).optional(),
|
|
82
|
-
cwd: z.string().min(1).optional(),
|
|
83
|
-
})
|
|
84
|
-
.strict(),
|
|
85
|
-
})
|
|
86
|
-
.strict(),
|
|
87
|
-
z
|
|
88
|
-
.object({
|
|
89
|
-
operation: z.literal("git.push"),
|
|
90
|
-
payload: z
|
|
91
|
-
.object({ branch: z.string().min(1).optional(), cwd: z.string().min(1).optional() })
|
|
92
|
-
.strict(),
|
|
93
|
-
})
|
|
94
|
-
.strict(),
|
|
95
|
-
z
|
|
96
|
-
.object({
|
|
97
|
-
operation: z.literal("hosting.pull_request"),
|
|
98
|
-
payload: z
|
|
99
|
-
.object({
|
|
100
|
-
title: z.string().min(1),
|
|
101
|
-
body: z.string().optional(),
|
|
102
|
-
draft: z.boolean().optional(),
|
|
103
|
-
target_branch: z.string().optional(),
|
|
104
|
-
babysit: z.boolean().optional(),
|
|
105
|
-
cwd: z.string().min(1).optional(),
|
|
106
|
-
})
|
|
107
|
-
.strict(),
|
|
108
|
-
})
|
|
109
|
-
.strict(),
|
|
110
|
-
z
|
|
111
|
-
.object({
|
|
112
|
-
operation: z.literal("hosting.merge"),
|
|
113
|
-
payload: z
|
|
114
|
-
.object({
|
|
115
|
-
target_branch: z.string().min(1).optional(),
|
|
116
|
-
source_branch: z.string().min(1).optional(),
|
|
117
|
-
cwd: z.string().min(1).optional(),
|
|
118
|
-
})
|
|
119
|
-
.strict(),
|
|
120
|
-
})
|
|
121
|
-
.strict(),
|
|
122
|
-
z
|
|
123
|
-
.object({
|
|
124
|
-
operation: z.literal("hosting.delete_branch"),
|
|
125
|
-
payload: z.object({ branch: z.string().min(1), cwd: z.string().min(1).optional() }).strict(),
|
|
126
|
-
})
|
|
127
|
-
.strict(),
|
|
128
|
-
z
|
|
129
|
-
.object({
|
|
130
|
-
operation: z.literal("youtrack.update"),
|
|
131
|
-
payload: z
|
|
132
|
-
.object({
|
|
133
|
-
issueId: z.string().min(1),
|
|
134
|
-
markdown: z.string().min(1),
|
|
135
|
-
minutes: z.number().positive().optional(),
|
|
136
|
-
})
|
|
137
|
-
.strict(),
|
|
138
|
-
})
|
|
139
|
-
.strict(),
|
|
140
|
-
z
|
|
141
|
-
.object({
|
|
142
|
-
operation: z.literal("youtrack.time"),
|
|
143
|
-
payload: z
|
|
144
|
-
.object({
|
|
145
|
-
issueId: z.string().min(1),
|
|
146
|
-
minutes: z.number().positive(),
|
|
147
|
-
text: z.string().optional(),
|
|
148
|
-
dateMs: z.number().finite().optional(),
|
|
149
|
-
})
|
|
150
|
-
.strict(),
|
|
151
|
-
})
|
|
152
|
-
.strict(),
|
|
153
|
-
z
|
|
154
|
-
.object({
|
|
155
|
-
operation: z.literal("youtrack.meeting"),
|
|
156
|
-
payload: z
|
|
157
|
-
.object({
|
|
158
|
-
issueId: z.string().min(1),
|
|
159
|
-
minutes: z.number().positive(),
|
|
160
|
-
text: z.string().min(1),
|
|
161
|
-
})
|
|
162
|
-
.strict(),
|
|
163
|
-
})
|
|
164
|
-
.strict(),
|
|
165
|
-
z
|
|
166
|
-
.object({
|
|
167
|
-
operation: z.literal("changelog.apply"),
|
|
168
|
-
payload: z
|
|
169
|
-
.object({
|
|
170
|
-
entries: z
|
|
171
|
-
.union([
|
|
172
|
-
z.record(z.string(), z.array(z.string())),
|
|
173
|
-
z.array(z.object({ category: z.string().min(1), text: z.string().min(1) }).strict()),
|
|
174
|
-
])
|
|
175
|
-
.optional(),
|
|
176
|
-
path: z.string().optional(),
|
|
177
|
-
normalize_only: z.boolean().optional(),
|
|
178
|
-
})
|
|
179
|
-
.strict(),
|
|
180
|
-
})
|
|
181
|
-
.strict(),
|
|
182
|
-
z
|
|
183
|
-
.object({
|
|
184
|
-
operation: z.literal("context.read"),
|
|
185
|
-
payload: z
|
|
186
|
-
.object({
|
|
187
|
-
kind: z.enum([
|
|
188
|
-
"git",
|
|
189
|
-
"pr",
|
|
190
|
-
"youtrack",
|
|
191
|
-
"github_issue",
|
|
192
|
-
"gitlab_issue",
|
|
193
|
-
"changelog",
|
|
194
|
-
"release",
|
|
195
|
-
"affected",
|
|
196
|
-
]),
|
|
197
|
-
range: z.string().optional(),
|
|
198
|
-
issueId: z.string().optional(),
|
|
199
|
-
issueUrl: z.string().optional(),
|
|
200
|
-
issueRef: z.string().optional(),
|
|
201
|
-
mode: z.string().optional(),
|
|
202
|
-
specPath: z.string().optional(),
|
|
203
|
-
planPath: z.string().optional(),
|
|
204
|
-
cwd: z.string().min(1).optional(),
|
|
205
|
-
})
|
|
206
|
-
.strict(),
|
|
207
|
-
})
|
|
208
|
-
.strict(),
|
|
209
|
-
]);
|
|
210
|
-
export type ExternalActionRequest = z.infer<typeof externalActionSchema>;
|
|
211
|
-
|
|
212
|
-
/** The complete external-action contract for hosts that register native tools. */
|
|
213
|
-
export const externalActionJsonSchema = (): z.core.JSONSchema.BaseSchema => ({
|
|
214
|
-
type: "object",
|
|
215
|
-
...z.toJSONSchema(externalActionSchema, { target: "draft-2020-12" }),
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
export type ExternalActionOperation = ExternalActionRequest["operation"];
|
|
219
|
-
|
|
220
|
-
export const externalActionRequest = (value: unknown): Result<ExternalActionRequest> => {
|
|
221
|
-
const parsed = externalActionSchema.safeParse(value);
|
|
222
|
-
return parsed.success
|
|
223
|
-
? success(null, null, parsed.data)
|
|
224
|
-
: failure("invalid_input", "external action payload is invalid", {
|
|
225
|
-
fields: parsed.error.issues.map((issue) => ({
|
|
226
|
-
path: issue.path.join("."),
|
|
227
|
-
reason: issue.message,
|
|
228
|
-
})),
|
|
229
|
-
});
|
|
230
|
-
};
|
|
231
|
-
export type ExternalActionRunner = <T>(
|
|
232
|
-
input: Omit<AuthorizedActionInput, "core">,
|
|
233
|
-
effect: (step?: string) => T | Result<T> | Promise<T | Result<T>>,
|
|
234
|
-
) => Promise<ExternalActionResult<T>>;
|
|
235
|
-
export type ExternalActionEffect = <T>(
|
|
236
|
-
operation: string,
|
|
237
|
-
effect: (
|
|
238
|
-
step?: string,
|
|
239
|
-
reservation?: ActionReservation,
|
|
240
|
-
) => T | Result<T> | Promise<T | Result<T>>,
|
|
241
|
-
) => Promise<ExternalActionResult<T>>;
|
|
242
|
-
export type ExternalActionBinding = (
|
|
243
|
-
operation: string,
|
|
244
|
-
) => Omit<AuthorizedActionInput, "core"> | ExternalActionResult<never>;
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Host adapters use this exact, canonical string as the native approval
|
|
248
|
-
* binding. It deliberately includes the concrete target/payload so an
|
|
249
|
-
* approved sentence cannot authorize a different effect by substring.
|
|
250
|
-
*/
|
|
251
|
-
export const externalActionDescriptor = (operation: string, payload: unknown): string =>
|
|
252
|
-
canonicalJson({
|
|
253
|
-
operation,
|
|
254
|
-
payload,
|
|
255
|
-
...(payload &&
|
|
256
|
-
typeof payload === "object" &&
|
|
257
|
-
!Array.isArray(payload) &&
|
|
258
|
-
"resolved" in payload &&
|
|
259
|
-
payload.resolved &&
|
|
260
|
-
typeof payload.resolved === "object" &&
|
|
261
|
-
Array.isArray((payload.resolved as { steps?: unknown }).steps)
|
|
262
|
-
? { steps: (payload.resolved as { steps: string[] }).steps }
|
|
263
|
-
: {}),
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Plan reservations never execute a git commit. A git.commit whose resolved
|
|
268
|
-
* payload carries a plan_steps array records the listed chain once and lets
|
|
269
|
-
* each listed single commit execute later under that authorization. Adapters
|
|
270
|
-
* must return plan success without running the commit effect, both for prior
|
|
271
|
-
* approvals and standing auto-approval; otherwise the reservation fails as a
|
|
272
|
-
* single commit requiring a message. Returns the listed count, else null.
|
|
273
|
-
*/
|
|
274
|
-
export const planReservationLength = (
|
|
275
|
-
operation: string,
|
|
276
|
-
descriptorPayload: unknown,
|
|
277
|
-
): number | null => {
|
|
278
|
-
if (operation !== "git.commit") return null;
|
|
279
|
-
const payload = (descriptorPayload ?? {}) as { plan_steps?: unknown };
|
|
280
|
-
return Array.isArray(payload.plan_steps) ? payload.plan_steps.length : null;
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
/** Compact host-facing help for the fixed optional-action surface. */
|
|
284
|
-
export const externalActionHelp =
|
|
285
|
-
"Fixed actions: Git/hosting operations accept cwd? for an action-time target repository: git.branch_setup {target_branch,action?,sdd_dir?,stash?,cwd?}; git.commit {message?,plan_steps?,plan_branch?,cwd?}; git.push {branch?,cwd?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?,cwd?} creates only after pre-verifying the approved source SHA and post-verifies the provider PR head before reporting success (the residual non-atomic source-SHA race is accepted); babysit:true opts into PR-ready follow-up and does not authorize merge or release; omission means no babysitting; hosting.merge {target_branch?,source_branch?,cwd?}; hosting.delete_branch {branch,cwd?} deletes only an exact merged PR head. YouTrack: update {issueId,markdown,minutes?}, time {issueId,minutes,text?,dateMs?}, meeting {issueId,minutes,text}. changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,cwd?,issueId?,issueUrl?,issueRef?}. context.read is read-only; all other actions require host-native approval (CLI needs TTY; caller-unattested MCP cannot mutate).";
|
|
286
|
-
|
|
287
|
-
export const externalActionRef = (
|
|
288
|
-
host: "opencode" | "pi" | "workit_cli",
|
|
289
|
-
actor: string,
|
|
290
|
-
descriptor: string,
|
|
291
|
-
): Ref => ({
|
|
292
|
-
kind: "host",
|
|
293
|
-
host,
|
|
294
|
-
handle: `external:${sha256({ actor, descriptor }).slice(0, 32)}`,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
export const externalActionState = (
|
|
298
|
-
store: TaskStore,
|
|
299
|
-
host: string,
|
|
300
|
-
actor: string,
|
|
301
|
-
operation: string,
|
|
302
|
-
) => {
|
|
303
|
-
const listed = store.listTasks();
|
|
304
|
-
const workspace = store.readWorkspace();
|
|
305
|
-
if (!listed.ok || !workspace.ok)
|
|
306
|
-
return failure("storage_error", "external action state is unavailable");
|
|
307
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
308
|
-
const candidates = listed.data.flatMap((task) => {
|
|
309
|
-
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
310
|
-
return task.decisions
|
|
311
|
-
.filter(
|
|
312
|
-
(entry) =>
|
|
313
|
-
entry.data.purpose === "action" &&
|
|
314
|
-
entry.data.response === "approved" &&
|
|
315
|
-
entry.data.revoked === null &&
|
|
316
|
-
entry.data.binding.approvedContent === operation,
|
|
317
|
-
)
|
|
318
|
-
.map((entry) => ({ task, entry }));
|
|
319
|
-
});
|
|
320
|
-
if (candidates.length !== 1)
|
|
321
|
-
return failure(
|
|
322
|
-
"permission_denied",
|
|
323
|
-
candidates.length === 0
|
|
324
|
-
? "no approved action is bound to the active task"
|
|
325
|
-
: "multiple approved actions match the active task",
|
|
326
|
-
);
|
|
327
|
-
return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
/** Find a prior decision for the same user-requested operation/payload even when its resolved target drifted. */
|
|
331
|
-
export const priorExternalAction = (
|
|
332
|
-
store: TaskStore,
|
|
333
|
-
host: string,
|
|
334
|
-
actor: string,
|
|
335
|
-
operation: string,
|
|
336
|
-
payload: unknown,
|
|
337
|
-
) => {
|
|
338
|
-
const listed = store.listTasks();
|
|
339
|
-
const workspace = store.readWorkspace();
|
|
340
|
-
if (!listed.ok || !workspace.ok)
|
|
341
|
-
return failure("storage_error", "external action state is unavailable");
|
|
342
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
343
|
-
const candidates = listed.data.flatMap((task) => {
|
|
344
|
-
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
345
|
-
return task.decisions
|
|
346
|
-
.filter((entry) => {
|
|
347
|
-
if (
|
|
348
|
-
entry.data.purpose !== "action" ||
|
|
349
|
-
entry.data.response !== "approved" ||
|
|
350
|
-
entry.data.revoked !== null
|
|
351
|
-
)
|
|
352
|
-
return false;
|
|
353
|
-
try {
|
|
354
|
-
const descriptor = JSON.parse(entry.data.binding.approvedContent) as {
|
|
355
|
-
operation?: unknown;
|
|
356
|
-
payload?: Record<string, unknown>;
|
|
357
|
-
};
|
|
358
|
-
if (
|
|
359
|
-
descriptor.operation !== operation ||
|
|
360
|
-
!descriptor.payload ||
|
|
361
|
-
typeof descriptor.payload !== "object"
|
|
362
|
-
)
|
|
363
|
-
return false;
|
|
364
|
-
const storedPayload = { ...descriptor.payload };
|
|
365
|
-
delete storedPayload.resolved;
|
|
366
|
-
return canonicalJson(storedPayload) === canonicalJson(payload);
|
|
367
|
-
} catch {
|
|
368
|
-
return false;
|
|
369
|
-
}
|
|
370
|
-
})
|
|
371
|
-
.map((entry) => ({ task, entry }));
|
|
372
|
-
});
|
|
373
|
-
if (candidates.length !== 1)
|
|
374
|
-
return failure(
|
|
375
|
-
"permission_denied",
|
|
376
|
-
candidates.length === 0
|
|
377
|
-
? "no prior external action matches this request"
|
|
378
|
-
: "multiple prior external actions match this request",
|
|
379
|
-
);
|
|
380
|
-
return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
/** Parse a plan-commit authorization descriptor (one approved list per plan). */
|
|
384
|
-
export const planCommitDescriptor = (
|
|
385
|
-
content: string,
|
|
386
|
-
): { steps: ChainStep[]; branch: string; snapshotHead: string | null; cwd?: string } | null => {
|
|
387
|
-
try {
|
|
388
|
-
const value = JSON.parse(content) as {
|
|
389
|
-
operation?: unknown;
|
|
390
|
-
payload?: {
|
|
391
|
-
plan_steps?: unknown;
|
|
392
|
-
plan_branch?: unknown;
|
|
393
|
-
cwd?: unknown;
|
|
394
|
-
resolved?: { head?: unknown; branch?: unknown };
|
|
395
|
-
};
|
|
396
|
-
};
|
|
397
|
-
if (value.operation !== "git.commit" || !value.payload) return null;
|
|
398
|
-
const steps = normalizeChainSteps(value.payload.plan_steps);
|
|
399
|
-
const branch = value.payload.plan_branch;
|
|
400
|
-
if (!steps || typeof branch !== "string" || !branch) return null;
|
|
401
|
-
const cwd = value.payload.cwd;
|
|
402
|
-
if (cwd !== undefined && (typeof cwd !== "string" || !path.isAbsolute(cwd))) return null;
|
|
403
|
-
const head = value.payload.resolved?.head;
|
|
404
|
-
return {
|
|
405
|
-
steps,
|
|
406
|
-
branch,
|
|
407
|
-
snapshotHead: typeof head === "string" ? head : null,
|
|
408
|
-
...(typeof cwd === "string" ? { cwd } : {}),
|
|
409
|
-
};
|
|
410
|
-
} catch {
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Chain lease: the recorded head must still be an ancestor of the current
|
|
417
|
-
* HEAD — history only moved forward. Rebase/reset underneath an approved
|
|
418
|
-
* chain invalidates it instead of executing on rewritten history.
|
|
419
|
-
*/
|
|
420
|
-
export const chainLeaseValid = (
|
|
421
|
-
store: TaskStore,
|
|
422
|
-
snapshotHead: string | null,
|
|
423
|
-
targetRoot = store.root,
|
|
424
|
-
): boolean => {
|
|
425
|
-
if (!snapshotHead) return true;
|
|
426
|
-
try {
|
|
427
|
-
const head = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
428
|
-
cwd: targetRoot,
|
|
429
|
-
encoding: "utf8",
|
|
430
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
431
|
-
}).trim();
|
|
432
|
-
if (head === snapshotHead) return true;
|
|
433
|
-
execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
|
|
434
|
-
cwd: targetRoot,
|
|
435
|
-
encoding: "utf8",
|
|
436
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
437
|
-
});
|
|
438
|
-
return true;
|
|
439
|
-
} catch {
|
|
440
|
-
return false;
|
|
441
|
-
}
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
export type PlanCommitAuthorization = {
|
|
445
|
-
task: TaskRecord;
|
|
446
|
-
entry: Entry<Decision>;
|
|
447
|
-
steps: ChainStep[];
|
|
448
|
-
branch: string;
|
|
449
|
-
snapshotHead: string | null;
|
|
450
|
-
completed: string[];
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Find the single active plan-commit authorization whose next unconsumed step
|
|
455
|
-
* is this exact commit message. Consumption order is enforced so each listed
|
|
456
|
-
* commit executes once, in plan order; unlisted or replayed messages find no
|
|
457
|
-
* authorization and require a fresh exact approval. Typed chain positions
|
|
458
|
-
* never match messages (a pathological message falls through to a fresh
|
|
459
|
-
* approval instead of consuming the wrong step), and a rewritten history
|
|
460
|
-
* breaks the lease.
|
|
461
|
-
*/
|
|
462
|
-
export const approvedPlanCommit = (
|
|
463
|
-
store: TaskStore,
|
|
464
|
-
host: string,
|
|
465
|
-
actor: string,
|
|
466
|
-
message: string,
|
|
467
|
-
targetRoot = store.root,
|
|
468
|
-
): Result<PlanCommitAuthorization> => {
|
|
469
|
-
if (typeof message !== "string" || !message)
|
|
470
|
-
return failure("invalid_input", "commit message is required");
|
|
471
|
-
const listed = store.listTasks();
|
|
472
|
-
const workspace = store.readWorkspace();
|
|
473
|
-
if (!listed.ok || !workspace.ok)
|
|
474
|
-
return failure("storage_error", "external action state is unavailable");
|
|
475
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
476
|
-
const matches = listed.data.flatMap((task) => {
|
|
477
|
-
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
478
|
-
return task.decisions.flatMap((entry) => {
|
|
479
|
-
const decision = entry.data;
|
|
480
|
-
if (
|
|
481
|
-
decision.purpose !== "action" ||
|
|
482
|
-
decision.response !== "approved" ||
|
|
483
|
-
decision.revoked !== null
|
|
484
|
-
)
|
|
485
|
-
return [];
|
|
486
|
-
if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
|
|
487
|
-
return [];
|
|
488
|
-
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
489
|
-
if (!plan) return [];
|
|
490
|
-
if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
|
|
491
|
-
const completed =
|
|
492
|
-
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
493
|
-
[];
|
|
494
|
-
const next = plan.steps[completed.length];
|
|
495
|
-
if (!next || next.kind !== "commit" || next.message !== message) return [];
|
|
496
|
-
if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
|
|
497
|
-
return [
|
|
498
|
-
{
|
|
499
|
-
task,
|
|
500
|
-
entry,
|
|
501
|
-
steps: plan.steps,
|
|
502
|
-
branch: plan.branch,
|
|
503
|
-
snapshotHead: plan.snapshotHead,
|
|
504
|
-
completed: [...completed],
|
|
505
|
-
},
|
|
506
|
-
];
|
|
507
|
-
});
|
|
508
|
-
});
|
|
509
|
-
if (matches.length !== 1)
|
|
510
|
-
return failure(
|
|
511
|
-
"permission_denied",
|
|
512
|
-
matches.length === 0
|
|
513
|
-
? "no plan commit authorization matches this message"
|
|
514
|
-
: "multiple plan authorizations match this message",
|
|
515
|
-
);
|
|
516
|
-
return success(workspace.data.revision, null, matches[0]);
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
export type ChainStepQuery =
|
|
520
|
-
| { operation: "git.branch_setup"; target: string; cwd?: string }
|
|
521
|
-
| { operation: "hosting.pull_request"; cwd?: string };
|
|
522
|
-
|
|
523
|
-
/** Match the next branch or PR step of an active chain authorization. */
|
|
524
|
-
export const approvedChainStep = (
|
|
525
|
-
store: TaskStore,
|
|
526
|
-
host: string,
|
|
527
|
-
actor: string,
|
|
528
|
-
query: ChainStepQuery,
|
|
529
|
-
): Result<PlanCommitAuthorization> => {
|
|
530
|
-
const listed = store.listTasks();
|
|
531
|
-
const workspace = store.readWorkspace();
|
|
532
|
-
if (!listed.ok || !workspace.ok)
|
|
533
|
-
return failure("storage_error", "external action state is unavailable");
|
|
534
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
535
|
-
const matches = listed.data.flatMap((task) => {
|
|
536
|
-
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
537
|
-
return task.decisions.flatMap((entry) => {
|
|
538
|
-
const decision = entry.data;
|
|
539
|
-
if (
|
|
540
|
-
decision.purpose !== "action" ||
|
|
541
|
-
decision.response !== "approved" ||
|
|
542
|
-
decision.revoked !== null
|
|
543
|
-
)
|
|
544
|
-
return [];
|
|
545
|
-
if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
|
|
546
|
-
return [];
|
|
547
|
-
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
548
|
-
if (!plan) return [];
|
|
549
|
-
const targetRoot = query.cwd ?? store.root;
|
|
550
|
-
if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
|
|
551
|
-
const completed =
|
|
552
|
-
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
553
|
-
[];
|
|
554
|
-
const next = plan.steps[completed.length];
|
|
555
|
-
if (!next) return [];
|
|
556
|
-
if (query.operation === "git.branch_setup") {
|
|
557
|
-
if (next.kind !== "branch" || next.target !== query.target) return [];
|
|
558
|
-
} else if (next.kind !== "pr") {
|
|
559
|
-
return [];
|
|
560
|
-
}
|
|
561
|
-
if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
|
|
562
|
-
return [
|
|
563
|
-
{
|
|
564
|
-
task,
|
|
565
|
-
entry,
|
|
566
|
-
steps: plan.steps,
|
|
567
|
-
branch: plan.branch,
|
|
568
|
-
snapshotHead: plan.snapshotHead,
|
|
569
|
-
completed: [...completed],
|
|
570
|
-
},
|
|
571
|
-
];
|
|
572
|
-
});
|
|
573
|
-
});
|
|
574
|
-
if (matches.length !== 1)
|
|
575
|
-
return failure(
|
|
576
|
-
"permission_denied",
|
|
577
|
-
matches.length === 0
|
|
578
|
-
? "no chain authorization matches this step"
|
|
579
|
-
: "multiple chain authorizations match this step",
|
|
580
|
-
);
|
|
581
|
-
return success(workspace.data.revision, null, matches[0]);
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
export const commitMessageFromDescriptor = (operation: string): string | undefined => {
|
|
585
|
-
try {
|
|
586
|
-
const descriptor = JSON.parse(operation) as {
|
|
587
|
-
operation?: unknown;
|
|
588
|
-
payload?: { message?: unknown };
|
|
589
|
-
};
|
|
590
|
-
return descriptor.operation === "git.commit" && typeof descriptor.payload?.message === "string"
|
|
591
|
-
? descriptor.payload.message
|
|
592
|
-
: undefined;
|
|
593
|
-
} catch {
|
|
594
|
-
return undefined;
|
|
595
|
-
}
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
const descriptorTargetRoot = (store: TaskStore, operation: string): string | null => {
|
|
599
|
-
try {
|
|
600
|
-
const descriptor = JSON.parse(operation) as { payload?: { cwd?: unknown } };
|
|
601
|
-
const cwd = descriptor.payload?.cwd;
|
|
602
|
-
if (cwd === undefined) return store.root;
|
|
603
|
-
return typeof cwd === "string" && path.isAbsolute(cwd) ? realpathSync(cwd) : null;
|
|
604
|
-
} catch {
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
|
-
};
|
|
608
|
-
|
|
609
|
-
/** Resolve a chain authorization into the exact binding an adapter needs
|
|
610
|
-
* to run one branch or PR step under the approved chain. Commit steps stay
|
|
611
|
-
* on the plan path; this covers the steps plans could never express. */
|
|
612
|
-
export const chainStepBinding = (
|
|
613
|
-
store: TaskStore,
|
|
614
|
-
host: string,
|
|
615
|
-
actor: string,
|
|
616
|
-
operation: string,
|
|
617
|
-
): {
|
|
618
|
-
taskId: Id;
|
|
619
|
-
decisionId: Id;
|
|
620
|
-
binding: Decision["binding"];
|
|
621
|
-
expectedRevision: Revision;
|
|
622
|
-
expectedWorkspaceRevision: Revision;
|
|
623
|
-
step: string;
|
|
624
|
-
} | null => {
|
|
625
|
-
let descriptor: { operation?: unknown; payload?: Record<string, unknown> };
|
|
626
|
-
try {
|
|
627
|
-
descriptor = JSON.parse(operation) as {
|
|
628
|
-
operation?: unknown;
|
|
629
|
-
payload?: Record<string, unknown>;
|
|
630
|
-
};
|
|
631
|
-
} catch {
|
|
632
|
-
return null;
|
|
633
|
-
}
|
|
634
|
-
const targetRoot = descriptorTargetRoot(store, operation);
|
|
635
|
-
if (!targetRoot) return null;
|
|
636
|
-
const query: ChainStepQuery | null =
|
|
637
|
-
descriptor.operation === "git.branch_setup" &&
|
|
638
|
-
typeof descriptor.payload?.target_branch === "string"
|
|
639
|
-
? {
|
|
640
|
-
operation: "git.branch_setup",
|
|
641
|
-
target: descriptor.payload.target_branch,
|
|
642
|
-
cwd: targetRoot,
|
|
643
|
-
}
|
|
644
|
-
: descriptor.operation === "hosting.pull_request"
|
|
645
|
-
? { operation: "hosting.pull_request", cwd: targetRoot }
|
|
646
|
-
: null;
|
|
647
|
-
if (!query) return null;
|
|
648
|
-
const chain = approvedChainStep(store, host, actor, query);
|
|
649
|
-
if (!chain.ok) return null;
|
|
650
|
-
// PR steps run on the chain branch; the branch step itself creates it.
|
|
651
|
-
if (query.operation === "hosting.pull_request") {
|
|
652
|
-
let branch: string;
|
|
653
|
-
try {
|
|
654
|
-
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
655
|
-
cwd: targetRoot,
|
|
656
|
-
encoding: "utf8",
|
|
657
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
658
|
-
}).trim();
|
|
659
|
-
} catch {
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
if (!branch || branch !== chain.data.branch) return null;
|
|
663
|
-
}
|
|
664
|
-
const workspace = store.readWorkspace();
|
|
665
|
-
if (!workspace.ok || !workspace.data) return null;
|
|
666
|
-
const next = chain.data.steps[chain.data.completed.length];
|
|
667
|
-
if (!next) return null;
|
|
668
|
-
return {
|
|
669
|
-
taskId: chain.data.task.id,
|
|
670
|
-
decisionId: chain.data.entry.id,
|
|
671
|
-
binding: chain.data.entry.data.binding,
|
|
672
|
-
expectedRevision: chain.data.task.revision,
|
|
673
|
-
expectedWorkspaceRevision: workspace.data.revision,
|
|
674
|
-
step: chainStepKey(next),
|
|
675
|
-
};
|
|
676
|
-
};
|
|
677
|
-
|
|
678
|
-
/** Resolve a plan-commit authorization into the exact binding an adapter needs
|
|
679
|
-
* to run one listed commit under the approved list. */
|
|
680
|
-
export const planCommitBinding = (
|
|
681
|
-
store: TaskStore,
|
|
682
|
-
host: string,
|
|
683
|
-
actor: string,
|
|
684
|
-
operation: string,
|
|
685
|
-
): {
|
|
686
|
-
taskId: Id;
|
|
687
|
-
decisionId: Id;
|
|
688
|
-
binding: Decision["binding"];
|
|
689
|
-
expectedRevision: Revision;
|
|
690
|
-
expectedWorkspaceRevision: Revision;
|
|
691
|
-
step: string;
|
|
692
|
-
} | null => {
|
|
693
|
-
const message = commitMessageFromDescriptor(operation);
|
|
694
|
-
if (!message) return null;
|
|
695
|
-
const targetRoot = descriptorTargetRoot(store, operation);
|
|
696
|
-
if (!targetRoot) return null;
|
|
697
|
-
const plan = approvedPlanCommit(store, host, actor, message, targetRoot);
|
|
698
|
-
if (!plan.ok) return null;
|
|
699
|
-
let branch: string;
|
|
700
|
-
try {
|
|
701
|
-
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
702
|
-
cwd: targetRoot,
|
|
703
|
-
encoding: "utf8",
|
|
704
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
705
|
-
}).trim();
|
|
706
|
-
} catch {
|
|
707
|
-
return null;
|
|
708
|
-
}
|
|
709
|
-
if (!branch || branch !== plan.data.branch) return null;
|
|
710
|
-
const workspace = store.readWorkspace();
|
|
711
|
-
if (!workspace.ok || !workspace.data) return null;
|
|
712
|
-
return {
|
|
713
|
-
taskId: plan.data.task.id,
|
|
714
|
-
decisionId: plan.data.entry.id,
|
|
715
|
-
binding: plan.data.entry.data.binding,
|
|
716
|
-
expectedRevision: plan.data.task.revision,
|
|
717
|
-
expectedWorkspaceRevision: workspace.data.revision,
|
|
718
|
-
step: message,
|
|
719
|
-
};
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
export const approvedExternalAction = (
|
|
723
|
-
store: TaskStore,
|
|
724
|
-
host: string,
|
|
725
|
-
actor: string,
|
|
726
|
-
operation: string,
|
|
727
|
-
) => {
|
|
728
|
-
const selected = externalActionState(store, host, actor, operation);
|
|
729
|
-
if (selected.ok) {
|
|
730
|
-
if (selected.data.entry.data.consumption !== null)
|
|
731
|
-
return failure(
|
|
732
|
-
selected.data.entry.data.consumption.state === "uncertain"
|
|
733
|
-
? "external_outcome_unknown"
|
|
734
|
-
: "permission_denied",
|
|
735
|
-
selected.data.entry.data.consumption.state === "uncertain"
|
|
736
|
-
? "previous external action outcome is unknown"
|
|
737
|
-
: "external action was already settled",
|
|
738
|
-
);
|
|
739
|
-
return selected;
|
|
740
|
-
}
|
|
741
|
-
// Intent carry for branch setup: an approval binds (target, base) intent,
|
|
742
|
-
// so unrelated HEAD or dirt moves between approval and execution re-resolve
|
|
743
|
-
// instead of demanding re-approval. Remote, existence, and target drift
|
|
744
|
-
// still fail closed through priorResolvedDrift.
|
|
745
|
-
const intent = approvedBranchSetupIntent(store, host, actor, operation);
|
|
746
|
-
if (intent.ok) {
|
|
747
|
-
if (intent.data.entry.data.consumption !== null)
|
|
748
|
-
return failure(
|
|
749
|
-
intent.data.entry.data.consumption.state === "uncertain"
|
|
750
|
-
? "external_outcome_unknown"
|
|
751
|
-
: "permission_denied",
|
|
752
|
-
intent.data.entry.data.consumption.state === "uncertain"
|
|
753
|
-
? "previous external action outcome is unknown"
|
|
754
|
-
: "external action was already settled",
|
|
755
|
-
);
|
|
756
|
-
return intent;
|
|
757
|
-
}
|
|
758
|
-
return selected;
|
|
759
|
-
};
|
|
760
|
-
|
|
761
|
-
/** Intent identity for branch setup includes the exact remote and local base commits. */
|
|
762
|
-
export const branchSetupIntent = (
|
|
763
|
-
descriptor: string,
|
|
764
|
-
): {
|
|
765
|
-
target: string;
|
|
766
|
-
base: string;
|
|
767
|
-
localBase: string | null;
|
|
768
|
-
remoteBase: string | null;
|
|
769
|
-
targetExists: boolean;
|
|
770
|
-
cwd?: string;
|
|
771
|
-
} | null => {
|
|
772
|
-
try {
|
|
773
|
-
const value = JSON.parse(descriptor) as {
|
|
774
|
-
operation?: unknown;
|
|
775
|
-
payload?: {
|
|
776
|
-
target_branch?: unknown;
|
|
777
|
-
cwd?: unknown;
|
|
778
|
-
resolved?: {
|
|
779
|
-
base_branch?: unknown;
|
|
780
|
-
local_base?: unknown;
|
|
781
|
-
remote_base?: unknown;
|
|
782
|
-
target_exists?: unknown;
|
|
783
|
-
};
|
|
784
|
-
};
|
|
785
|
-
};
|
|
786
|
-
if (value.operation !== "git.branch_setup" || !value.payload) return null;
|
|
787
|
-
const { target_branch, resolved } = value.payload;
|
|
788
|
-
if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
|
|
789
|
-
if (
|
|
790
|
-
value.payload.cwd !== undefined &&
|
|
791
|
-
(typeof value.payload.cwd !== "string" || !path.isAbsolute(value.payload.cwd))
|
|
792
|
-
)
|
|
793
|
-
return null;
|
|
794
|
-
if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
|
|
795
|
-
return null;
|
|
796
|
-
return {
|
|
797
|
-
target: target_branch,
|
|
798
|
-
base: resolved.base_branch,
|
|
799
|
-
localBase: typeof resolved.local_base === "string" ? resolved.local_base : null,
|
|
800
|
-
remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
|
|
801
|
-
targetExists: resolved.target_exists,
|
|
802
|
-
...(typeof value.payload.cwd === "string" ? { cwd: value.payload.cwd } : {}),
|
|
803
|
-
};
|
|
804
|
-
} catch {
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
/** Find the single unconsumed branch-setup approval with the same intent as
|
|
810
|
-
* the current descriptor, regardless of head or dirt moves since approval. */
|
|
811
|
-
export const approvedBranchSetupIntent = (
|
|
812
|
-
store: TaskStore,
|
|
813
|
-
host: string,
|
|
814
|
-
actor: string,
|
|
815
|
-
operation: string,
|
|
816
|
-
) => {
|
|
817
|
-
const current = branchSetupIntent(operation);
|
|
818
|
-
if (!current)
|
|
819
|
-
return failure("permission_denied", "no approved action is bound to the active task");
|
|
820
|
-
const listed = store.listTasks();
|
|
821
|
-
const workspace = store.readWorkspace();
|
|
822
|
-
if (!listed.ok || !workspace.ok)
|
|
823
|
-
return failure("storage_error", "external action state is unavailable");
|
|
824
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
825
|
-
const candidates = listed.data.flatMap((task) => {
|
|
826
|
-
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
827
|
-
return task.decisions
|
|
828
|
-
.filter(
|
|
829
|
-
(entry) =>
|
|
830
|
-
entry.data.purpose === "action" &&
|
|
831
|
-
entry.data.response === "approved" &&
|
|
832
|
-
entry.data.revoked === null,
|
|
833
|
-
)
|
|
834
|
-
.map((entry) => ({ task, entry }));
|
|
835
|
-
});
|
|
836
|
-
const matches = candidates.filter(({ entry }) => {
|
|
837
|
-
const intent = branchSetupIntent(entry.data.binding.approvedContent);
|
|
838
|
-
return (
|
|
839
|
-
intent !== null &&
|
|
840
|
-
(intent.cwd ?? store.root) === (current.cwd ?? store.root) &&
|
|
841
|
-
intent.target === current.target &&
|
|
842
|
-
intent.base === current.base &&
|
|
843
|
-
intent.localBase === current.localBase &&
|
|
844
|
-
intent.remoteBase === current.remoteBase &&
|
|
845
|
-
intent.targetExists === current.targetExists
|
|
846
|
-
);
|
|
847
|
-
});
|
|
848
|
-
if (matches.length !== 1)
|
|
849
|
-
return failure(
|
|
850
|
-
"permission_denied",
|
|
851
|
-
matches.length === 0
|
|
852
|
-
? "no approved action is bound to the active task"
|
|
853
|
-
: "multiple approved actions match the active task",
|
|
854
|
-
);
|
|
855
|
-
return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* Re-resolve guard: when a prior approval exists for the same requested
|
|
860
|
-
* operation/payload, the intent baseline must still match. For branch setup
|
|
861
|
-
* only (target, base, remote base, existence) bind the effect — head and
|
|
862
|
-
* dirt moves re-resolve instead of demanding re-approval. Drift names the
|
|
863
|
-
* moved element and returns not_started so the agent re-presents the
|
|
864
|
-
* current state instead of executing a stale effect.
|
|
865
|
-
*/
|
|
866
|
-
export const priorResolvedDrift = (
|
|
867
|
-
store: TaskStore,
|
|
868
|
-
host: string,
|
|
869
|
-
actor: string,
|
|
870
|
-
operation: string,
|
|
871
|
-
payload: unknown,
|
|
872
|
-
currentResolved: unknown,
|
|
873
|
-
): Result<null> => {
|
|
874
|
-
const prior = priorExternalAction(store, host, actor, operation, payload);
|
|
875
|
-
if (!prior.ok) return success(null, null, null);
|
|
876
|
-
let baseline: unknown;
|
|
877
|
-
try {
|
|
878
|
-
const parsed = JSON.parse(prior.data.entry.data.binding.approvedContent) as {
|
|
879
|
-
payload?: { resolved?: unknown };
|
|
880
|
-
};
|
|
881
|
-
baseline = parsed.payload?.resolved;
|
|
882
|
-
} catch {
|
|
883
|
-
return success(null, null, null);
|
|
884
|
-
}
|
|
885
|
-
if (baseline === undefined || baseline === null) return success(null, null, null);
|
|
886
|
-
if (operation === "git.branch_setup") {
|
|
887
|
-
const before = branchSetupIntent(prior.data.entry.data.binding.approvedContent);
|
|
888
|
-
let after: ReturnType<typeof branchSetupIntent> = null;
|
|
889
|
-
try {
|
|
890
|
-
after = branchSetupIntent(
|
|
891
|
-
JSON.stringify({
|
|
892
|
-
operation,
|
|
893
|
-
payload: { ...(payload as Record<string, unknown>), resolved: currentResolved },
|
|
894
|
-
}),
|
|
895
|
-
);
|
|
896
|
-
} catch {
|
|
897
|
-
after = null;
|
|
898
|
-
}
|
|
899
|
-
if (before && after) {
|
|
900
|
-
const moved =
|
|
901
|
-
before.target !== after.target
|
|
902
|
-
? "target_branch"
|
|
903
|
-
: before.base !== after.base
|
|
904
|
-
? "base_branch"
|
|
905
|
-
: before.remoteBase !== after.remoteBase
|
|
906
|
-
? "remote_base"
|
|
907
|
-
: before.localBase !== after.localBase
|
|
908
|
-
? "local_base"
|
|
909
|
-
: before.targetExists !== after.targetExists
|
|
910
|
-
? "target_exists"
|
|
911
|
-
: null;
|
|
912
|
-
if (moved === null) return success(null, null, null);
|
|
913
|
-
return failure(
|
|
914
|
-
"invalid_input",
|
|
915
|
-
`repository state changed since approval (${moved} moved); show the current state and approve the action again`,
|
|
916
|
-
{ outcome: "not_started", operation },
|
|
917
|
-
);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
|
|
921
|
-
return failure(
|
|
922
|
-
"invalid_input",
|
|
923
|
-
"repository state changed since approval; show the current state and approve the action again",
|
|
924
|
-
{ outcome: "not_started", operation },
|
|
925
|
-
);
|
|
926
|
-
};
|
|
927
|
-
|
|
928
|
-
export const createAuthorizedExternalActionRunner =
|
|
929
|
-
(core: WorkitCore, bind: ExternalActionBinding, coordinationRoot: string): ExternalActionEffect =>
|
|
930
|
-
async <T>(
|
|
931
|
-
operation: string,
|
|
932
|
-
effect: (
|
|
933
|
-
step?: string,
|
|
934
|
-
reservation?: ActionReservation,
|
|
935
|
-
) => T | Result<T> | Promise<T | Result<T>>,
|
|
936
|
-
) => {
|
|
937
|
-
let steps: string[] = [];
|
|
938
|
-
try {
|
|
939
|
-
const parsed = JSON.parse(operation) as { steps?: unknown };
|
|
940
|
-
if (Array.isArray(parsed.steps) && parsed.steps.every((step) => typeof step === "string"))
|
|
941
|
-
steps = parsed.steps;
|
|
942
|
-
} catch {
|
|
943
|
-
/* non-descriptor callers remain one-shot */
|
|
944
|
-
}
|
|
945
|
-
const sequence = steps.length ? steps : [undefined];
|
|
946
|
-
let latest: ExternalActionResult<T> | undefined;
|
|
947
|
-
for (const step of sequence) {
|
|
948
|
-
const binding = bind(operation);
|
|
949
|
-
if ("ok" in binding) return binding;
|
|
950
|
-
const targetRoot = externalActionTargetRoot(operation, coordinationRoot);
|
|
951
|
-
if (targetRoot === null)
|
|
952
|
-
return failure("capability_unavailable", "external action target could not be locked", {
|
|
953
|
-
outcome: "not_started",
|
|
954
|
-
});
|
|
955
|
-
const withActionLock: WithExternalActionLock | undefined = !sameDirectoryIdentity(
|
|
956
|
-
targetRoot,
|
|
957
|
-
coordinationRoot,
|
|
958
|
-
)
|
|
959
|
-
? (run) => new TaskStore(targetRoot).withExternalActionLock(run, true)
|
|
960
|
-
: undefined;
|
|
961
|
-
const result = await runAuthorizedExternalAction(
|
|
962
|
-
{ core, ...binding, ...(step ? { step } : {}) },
|
|
963
|
-
(reservation) => effect(step, reservation),
|
|
964
|
-
withActionLock,
|
|
965
|
-
);
|
|
966
|
-
latest = result;
|
|
967
|
-
if (!result.ok) return result;
|
|
968
|
-
}
|
|
969
|
-
return latest ?? failure("recovery_required", "external action did not execute");
|
|
970
|
-
};
|
|
971
|
-
|
|
972
|
-
const externalActionTargetRoot = (operation: string, coordinationRoot: string): string | null => {
|
|
973
|
-
let value: unknown;
|
|
974
|
-
try {
|
|
975
|
-
value = JSON.parse(operation);
|
|
976
|
-
} catch {
|
|
977
|
-
return realpathSync(coordinationRoot);
|
|
978
|
-
}
|
|
979
|
-
if (typeof value !== "object" || value === null) return realpathSync(coordinationRoot);
|
|
980
|
-
const descriptor = value as { operation?: unknown; payload?: unknown };
|
|
981
|
-
if (
|
|
982
|
-
typeof descriptor.operation !== "string" ||
|
|
983
|
-
typeof descriptor.payload !== "object" ||
|
|
984
|
-
descriptor.payload === null
|
|
985
|
-
)
|
|
986
|
-
return realpathSync(coordinationRoot);
|
|
987
|
-
if (
|
|
988
|
-
!descriptor.operation.startsWith("git.") &&
|
|
989
|
-
!descriptor.operation.startsWith("hosting.") &&
|
|
990
|
-
descriptor.operation !== "changelog.apply"
|
|
991
|
-
)
|
|
992
|
-
return realpathSync(coordinationRoot);
|
|
993
|
-
const payload = descriptor.payload as Record<string, unknown>;
|
|
994
|
-
const cwd = typeof payload.cwd === "string" ? payload.cwd : coordinationRoot;
|
|
995
|
-
try {
|
|
996
|
-
return realpathSync(path.resolve(coordinationRoot, cwd));
|
|
997
|
-
} catch {
|
|
998
|
-
return null;
|
|
999
|
-
}
|
|
1000
|
-
};
|
|
1001
|
-
|
|
1002
|
-
export type NativeExternalActionObservation = {
|
|
1003
|
-
actor: string;
|
|
1004
|
-
actionRef: Ref;
|
|
1005
|
-
outcome: "reserve" | "succeeded" | "not_started" | "unknown";
|
|
1006
|
-
callId?: string;
|
|
1007
|
-
taskRevision?: Revision;
|
|
1008
|
-
workspaceRevision?: Revision;
|
|
1009
|
-
};
|
|
1010
|
-
|
|
1011
|
-
export const nativeExternalActionObservation = (
|
|
1012
|
-
actor: string,
|
|
1013
|
-
actionRef: Ref,
|
|
1014
|
-
outcome: NativeExternalActionObservation["outcome"],
|
|
1015
|
-
callId = actionRef.kind === "host" ? actionRef.handle : "external-action",
|
|
1016
|
-
revisions?: { taskRevision: Revision; workspaceRevision: Revision },
|
|
1017
|
-
): NativeExternalActionObservation =>
|
|
1018
|
-
revisions
|
|
1019
|
-
? { actor, actionRef, outcome, callId, ...revisions }
|
|
1020
|
-
: { actor, actionRef, outcome, callId };
|
|
1021
|
-
|
|
1022
|
-
export const readNativeExternalActionObservation = (
|
|
1023
|
-
value: unknown,
|
|
1024
|
-
): NativeExternalActionObservation | null => {
|
|
1025
|
-
if (typeof value !== "object" || value === null) return null;
|
|
1026
|
-
const candidate = value as Record<string, unknown>;
|
|
1027
|
-
if (
|
|
1028
|
-
typeof candidate.actor !== "string" ||
|
|
1029
|
-
!refSchema.safeParse(candidate.actionRef).success ||
|
|
1030
|
-
!["reserve", "succeeded", "not_started", "unknown"].includes(String(candidate.outcome)) ||
|
|
1031
|
-
(candidate.callId !== undefined && typeof candidate.callId !== "string") ||
|
|
1032
|
-
(candidate.taskRevision !== undefined && typeof candidate.taskRevision !== "string") ||
|
|
1033
|
-
(candidate.workspaceRevision !== undefined && typeof candidate.workspaceRevision !== "string")
|
|
1034
|
-
)
|
|
1035
|
-
return null;
|
|
1036
|
-
return {
|
|
1037
|
-
actor: candidate.actor,
|
|
1038
|
-
actionRef: candidate.actionRef as Ref,
|
|
1039
|
-
outcome: candidate.outcome as NativeExternalActionObservation["outcome"],
|
|
1040
|
-
...(candidate.callId === undefined ? {} : { callId: candidate.callId }),
|
|
1041
|
-
...(candidate.taskRevision === undefined ? {} : { taskRevision: candidate.taskRevision }),
|
|
1042
|
-
...(candidate.workspaceRevision === undefined
|
|
1043
|
-
? {}
|
|
1044
|
-
: { workspaceRevision: candidate.workspaceRevision }),
|
|
1045
|
-
};
|
|
1046
|
-
};
|
|
1047
|
-
|
|
1048
|
-
export const matchesNativeExternalAction = (
|
|
1049
|
-
value: unknown,
|
|
1050
|
-
expected: {
|
|
1051
|
-
actor: string;
|
|
1052
|
-
actionRef: Ref;
|
|
1053
|
-
outcome: NativeExternalActionObservation["outcome"];
|
|
1054
|
-
taskRevision?: Revision;
|
|
1055
|
-
workspaceRevision?: Revision;
|
|
1056
|
-
},
|
|
1057
|
-
): boolean => {
|
|
1058
|
-
const observation = readNativeExternalActionObservation(value);
|
|
1059
|
-
return (
|
|
1060
|
-
observation !== null &&
|
|
1061
|
-
observation.actor === expected.actor &&
|
|
1062
|
-
observation.outcome === expected.outcome &&
|
|
1063
|
-
(expected.taskRevision === undefined || observation.taskRevision === expected.taskRevision) &&
|
|
1064
|
-
(expected.workspaceRevision === undefined ||
|
|
1065
|
-
observation.workspaceRevision === expected.workspaceRevision) &&
|
|
1066
|
-
canonicalJson(observation.actionRef) === canonicalJson(expected.actionRef)
|
|
1067
|
-
);
|
|
1068
|
-
};
|
|
1069
|
-
|
|
1070
|
-
/**
|
|
1071
|
-
* Run one concrete, already-authorized external effect through the core's
|
|
1072
|
-
* one-time reservation and settlement transitions. The adapter supplies the
|
|
1073
|
-
* host-observed action evidence; this helper never turns an arbitrary payload
|
|
1074
|
-
* into an external effect and never retries an uncertain outcome.
|
|
1075
|
-
*/
|
|
1076
|
-
export async function runAuthorizedExternalAction<T>(
|
|
1077
|
-
input: AuthorizedActionInput,
|
|
1078
|
-
effect: (reservation: ActionReservation) => T | Result<T> | Promise<T | Result<T>>,
|
|
1079
|
-
withActionLock?: WithExternalActionLock,
|
|
1080
|
-
): Promise<ExternalActionResult<T>> {
|
|
1081
|
-
if (input.core.isDelegatedCaller())
|
|
1082
|
-
return failure("permission_denied", "delegated workers cannot run external actions");
|
|
1083
|
-
if (input.available === false)
|
|
1084
|
-
return failure(
|
|
1085
|
-
"capability_unavailable",
|
|
1086
|
-
`${input.capability ?? "external action"} is unavailable`,
|
|
1087
|
-
{ capability: input.capability ?? "external_action" },
|
|
1088
|
-
);
|
|
1089
|
-
|
|
1090
|
-
const reserved = input.core.reserveAction({
|
|
1091
|
-
taskId: input.taskId,
|
|
1092
|
-
decisionId: input.decisionId,
|
|
1093
|
-
actionRef: input.actionRef,
|
|
1094
|
-
expectedRevision: input.expectedRevision,
|
|
1095
|
-
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1096
|
-
...(input.binding ? { binding: input.binding } : {}),
|
|
1097
|
-
...(input.step ? { step: input.step } : {}),
|
|
1098
|
-
observation: input.reserveObservation,
|
|
1099
|
-
});
|
|
1100
|
-
if (!reserved.ok) return reserved;
|
|
1101
|
-
|
|
1102
|
-
const settle = (
|
|
1103
|
-
outcome: "succeeded" | "not_started" | "unknown",
|
|
1104
|
-
refreshed = false,
|
|
1105
|
-
): ExternalActionResult<unknown> => {
|
|
1106
|
-
try {
|
|
1107
|
-
const revisions =
|
|
1108
|
-
refreshed && input.refresh
|
|
1109
|
-
? input.refresh()
|
|
1110
|
-
: {
|
|
1111
|
-
expectedRevision: reserved.data.taskRevision,
|
|
1112
|
-
expectedWorkspaceRevision: reserved.data.workspaceRevision,
|
|
1113
|
-
};
|
|
1114
|
-
const result = input.core.settleAction({
|
|
1115
|
-
...reserved.data,
|
|
1116
|
-
taskRevision: revisions.expectedRevision,
|
|
1117
|
-
workspaceRevision: revisions.expectedWorkspaceRevision,
|
|
1118
|
-
outcome,
|
|
1119
|
-
...(input.step ? { step: input.step } : {}),
|
|
1120
|
-
observation: input.settleObservation(outcome, {
|
|
1121
|
-
taskRevision: revisions.expectedRevision,
|
|
1122
|
-
workspaceRevision: revisions.expectedWorkspaceRevision,
|
|
1123
|
-
}),
|
|
1124
|
-
});
|
|
1125
|
-
return result;
|
|
1126
|
-
} catch {
|
|
1127
|
-
return failure("external_outcome_unknown", "external action settlement is unavailable", {
|
|
1128
|
-
operation: "external_action",
|
|
1129
|
-
outcome: "unknown",
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
};
|
|
1133
|
-
const settleWithRefresh = (
|
|
1134
|
-
outcome: "succeeded" | "not_started" | "unknown",
|
|
1135
|
-
first: ExternalActionResult<unknown>,
|
|
1136
|
-
): ExternalActionResult<unknown> =>
|
|
1137
|
-
!first.ok && first.code === "revision_conflict" && input.refresh
|
|
1138
|
-
? settle(outcome, true)
|
|
1139
|
-
: first;
|
|
1140
|
-
|
|
1141
|
-
let effectStarted = false;
|
|
1142
|
-
const runEffect = async (): Promise<ExternalActionResult<T>> => {
|
|
1143
|
-
effectStarted = true;
|
|
1144
|
-
let effectValue: T | Result<T>;
|
|
1145
|
-
try {
|
|
1146
|
-
effectValue = await effect(reserved.data);
|
|
1147
|
-
} catch {
|
|
1148
|
-
const settled = settleWithRefresh("unknown", settle("unknown"));
|
|
1149
|
-
if (!settled.ok) return settled;
|
|
1150
|
-
return failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
1151
|
-
operation: "external_action",
|
|
1152
|
-
outcome: "unknown",
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
if (
|
|
1156
|
-
typeof effectValue === "object" &&
|
|
1157
|
-
effectValue !== null &&
|
|
1158
|
-
"ok" in effectValue &&
|
|
1159
|
-
typeof (effectValue as { ok?: unknown }).ok === "boolean"
|
|
1160
|
-
) {
|
|
1161
|
-
const contract = effectValue;
|
|
1162
|
-
if (!contract.ok) {
|
|
1163
|
-
const preflight =
|
|
1164
|
-
typeof contract.details === "object" &&
|
|
1165
|
-
contract.details !== null &&
|
|
1166
|
-
(contract.details as Record<string, unknown>).outcome === "not_started";
|
|
1167
|
-
const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
|
|
1168
|
-
const settled = settleWithRefresh(outcome, settle(outcome));
|
|
1169
|
-
if (!settled.ok) return settled;
|
|
1170
|
-
return contract;
|
|
1171
|
-
}
|
|
1172
|
-
effectValue = contract.data;
|
|
1173
|
-
}
|
|
1174
|
-
const settled = settleWithRefresh("succeeded", settle("succeeded"));
|
|
1175
|
-
if (!settled.ok) return settled;
|
|
1176
|
-
return {
|
|
1177
|
-
ok: true,
|
|
1178
|
-
schemaVersion: 1,
|
|
1179
|
-
revision: settled.revision,
|
|
1180
|
-
workspaceRevision: settled.workspaceRevision,
|
|
1181
|
-
data: effectValue as T,
|
|
1182
|
-
};
|
|
1183
|
-
};
|
|
1184
|
-
let result: ExternalActionResult<T>;
|
|
1185
|
-
try {
|
|
1186
|
-
result = withActionLock ? await withActionLock(runEffect) : await runEffect();
|
|
1187
|
-
} catch {
|
|
1188
|
-
if (effectStarted)
|
|
1189
|
-
return failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
1190
|
-
operation: "external_action",
|
|
1191
|
-
outcome: "unknown",
|
|
1192
|
-
});
|
|
1193
|
-
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1194
|
-
if (!settled.ok) return settled;
|
|
1195
|
-
return failure("writer_conflict", "target checkout was unavailable before the effect", {
|
|
1196
|
-
outcome: "not_started",
|
|
1197
|
-
});
|
|
1198
|
-
}
|
|
1199
|
-
if (!effectStarted) {
|
|
1200
|
-
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1201
|
-
if (!settled.ok) return settled;
|
|
1202
|
-
}
|
|
1203
|
-
return result;
|
|
1204
|
-
}
|