@brainervirus/workit-core 1.1.1 → 1.2.1
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/core/authority.ts +25 -7
- package/src/core/auto-approval.ts +10 -18
- package/src/core/external-action-effects.ts +227 -48
- package/src/core/external-action.ts +34 -41
- package/src/core/support-matrix.ts +4 -1
- package/src/core/task-contract.ts +10 -3
- package/src/core/task-engine.ts +89 -29
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/workers.ts +17 -0
- package/src/core.ts +1 -0
package/package.json
CHANGED
package/src/core/authority.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type Utc,
|
|
24
24
|
} from "./task-contract";
|
|
25
25
|
import { TaskStore } from "./task-store";
|
|
26
|
+
import { currentWriterOwnsTask } from "./workers";
|
|
26
27
|
|
|
27
28
|
export type NativeAuthorityContext = {
|
|
28
29
|
now: Utc;
|
|
@@ -542,6 +543,27 @@ export const storedDecisionApplicable = (
|
|
|
542
543
|
verifyDecisionContentAtRoot(store.root, entry.data.binding).ok,
|
|
543
544
|
);
|
|
544
545
|
|
|
546
|
+
const approvalAuthorityMatches = (
|
|
547
|
+
store: TaskStore,
|
|
548
|
+
task: TaskRecord,
|
|
549
|
+
approval: Provenance,
|
|
550
|
+
current: Provenance,
|
|
551
|
+
): boolean => {
|
|
552
|
+
if (approval.kind !== "host_observed" || approval.receipts.length === 0) return false;
|
|
553
|
+
if (provenanceMatches(approval, current)) return true;
|
|
554
|
+
const session = current.kind === "host_observed" ? current.session : null;
|
|
555
|
+
if (!session || session.kind !== "host") return false;
|
|
556
|
+
const workspace = store.readWorkspace();
|
|
557
|
+
return (
|
|
558
|
+
workspace.ok &&
|
|
559
|
+
workspace.data !== null &&
|
|
560
|
+
currentWriterOwnsTask(task, workspace.data, {
|
|
561
|
+
host: session.host,
|
|
562
|
+
actor: session.handle,
|
|
563
|
+
})
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
545
567
|
const validateAction = (
|
|
546
568
|
store: TaskStore,
|
|
547
569
|
task: TaskRecord,
|
|
@@ -574,11 +596,7 @@ const validateAction = (
|
|
|
574
596
|
return failure("permission_denied", "native reservation authority does not match decision");
|
|
575
597
|
if (decision.response !== "approved")
|
|
576
598
|
return failure("permission_denied", "decision was rejected");
|
|
577
|
-
if (
|
|
578
|
-
entry.provenance.kind !== "host_observed" ||
|
|
579
|
-
entry.provenance.receipts.length === 0 ||
|
|
580
|
-
!provenanceMatches(entry.provenance, authority.provenance)
|
|
581
|
-
)
|
|
599
|
+
if (!approvalAuthorityMatches(store, task, entry.provenance, authority.provenance))
|
|
582
600
|
return failure("permission_denied", "action approval lacks native receipt assurance");
|
|
583
601
|
// Standing auto-approval receipts carry no live question. They authorize
|
|
584
602
|
// only while the referenced workspace rule still covers the operation —
|
|
@@ -793,7 +811,7 @@ export function settleAction(input: SettleActionInput): Result<Entry<Decision>>
|
|
|
793
811
|
if (!entry) return failure("not_found", "decision not found");
|
|
794
812
|
if (!authorityDecisionMatches(authority, entry.data))
|
|
795
813
|
return failure("permission_denied", "native settlement authority does not match decision");
|
|
796
|
-
if (!
|
|
814
|
+
if (!approvalAuthorityMatches(input.store, current, entry.provenance, authority.provenance))
|
|
797
815
|
return failure("permission_denied", "native settlement caller does not match approval");
|
|
798
816
|
if (entry.data.purpose !== "action" || entry.data.digest !== decisionDigest(entry.data))
|
|
799
817
|
return failure("permission_denied", "decision binding is invalid");
|
|
@@ -931,7 +949,7 @@ export function reconcileAction(input: ReconcileActionInput): Result<Entry<Decis
|
|
|
931
949
|
return failure("not_found", "decision not found");
|
|
932
950
|
if (
|
|
933
951
|
!authorityDecisionMatches(authority, entry.data) ||
|
|
934
|
-
!
|
|
952
|
+
!approvalAuthorityMatches(input.store, current, entry.provenance, authority.provenance)
|
|
935
953
|
)
|
|
936
954
|
return failure("permission_denied", "native reconciliation caller does not match approval");
|
|
937
955
|
if (
|
|
@@ -13,9 +13,10 @@ import {
|
|
|
13
13
|
type Revision,
|
|
14
14
|
type TaskRecord,
|
|
15
15
|
} from "./task-contract";
|
|
16
|
-
import
|
|
16
|
+
import { TaskStore } from "./task-store";
|
|
17
17
|
import type { WorkitCore } from "./task-engine";
|
|
18
18
|
import type { Decision } from "./task-contract";
|
|
19
|
+
import { currentWriterOwnsTask } from "./workers";
|
|
19
20
|
|
|
20
21
|
/** Action classes auto-approval can cover. Unknown classes never match. */
|
|
21
22
|
export const AUTO_CLASSES = ["branch", "commit", "push", "pr", "merge"] as const;
|
|
@@ -154,14 +155,10 @@ export const verifyStandingApproval = (
|
|
|
154
155
|
): Result<Provenance> => {
|
|
155
156
|
if (task.status !== "active")
|
|
156
157
|
return failure("invalid_transition", "only active tasks can authorize actions");
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
session.host !== caller.host ||
|
|
162
|
-
session.handle !== caller.actor
|
|
163
|
-
)
|
|
164
|
-
return failure("permission_denied", "standing approval requires the lead session");
|
|
158
|
+
const workspace = new TaskStore(root).readWorkspace();
|
|
159
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
160
|
+
if (!workspace.data || !currentWriterOwnsTask(task, workspace.data, caller))
|
|
161
|
+
return failure("permission_denied", "standing approval requires the current writer session");
|
|
165
162
|
const standing = binding.standing;
|
|
166
163
|
if (!standing || typeof standing.workspace !== "string" || typeof standing.class !== "string")
|
|
167
164
|
return failure("invalid_input", "standing approval needs a workspace and class");
|
|
@@ -243,19 +240,14 @@ export const standingAutoApplies = (
|
|
|
243
240
|
const listed = store.listTasks();
|
|
244
241
|
const workspace = store.readWorkspace();
|
|
245
242
|
if (!listed.ok || !workspace.ok || !workspace.data) return null;
|
|
246
|
-
const
|
|
247
|
-
(task)
|
|
248
|
-
task.status === "active" &&
|
|
249
|
-
task.workspaceId === workspace.data!.id &&
|
|
250
|
-
task.intent?.provenance?.session?.kind === "host" &&
|
|
251
|
-
(task.intent.provenance.session as { host?: unknown; handle?: unknown }).host === host &&
|
|
252
|
-
(task.intent.provenance.session as { host?: unknown; handle?: unknown }).handle === actor,
|
|
243
|
+
const task = listed.data.find((task) =>
|
|
244
|
+
currentWriterOwnsTask(task, workspace.data!, { host, actor }),
|
|
253
245
|
);
|
|
254
|
-
if (
|
|
246
|
+
if (!task) return null;
|
|
255
247
|
const resolved = resolveAutoApproval(store.root);
|
|
256
248
|
if (resolved.status !== "on") return null;
|
|
257
249
|
return {
|
|
258
|
-
task
|
|
250
|
+
task,
|
|
259
251
|
workspaceId: workspace.data.id,
|
|
260
252
|
workspaceRevision: workspace.data.revision,
|
|
261
253
|
cls,
|
|
@@ -25,7 +25,15 @@ import {
|
|
|
25
25
|
} from "./youtrack";
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
failure,
|
|
30
|
+
success,
|
|
31
|
+
sha256,
|
|
32
|
+
type Caller,
|
|
33
|
+
type Owner,
|
|
34
|
+
type Result,
|
|
35
|
+
type Ref,
|
|
36
|
+
} from "./task-contract";
|
|
29
37
|
import { changelogApply, changelogApplyPreview } from "./changelog";
|
|
30
38
|
import type { ExternalActionRequest } from "./external-action";
|
|
31
39
|
import { externalActionDescriptor, externalActionRequest } from "./external-action";
|
|
@@ -33,7 +41,7 @@ import { normalizeChainSteps } from "./authority";
|
|
|
33
41
|
import { resolveInside, run as coreRun } from "../core";
|
|
34
42
|
import { vcsConfig } from "./vcs-config";
|
|
35
43
|
import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
|
|
36
|
-
import { assertProductWriteAllowed } from "./workers";
|
|
44
|
+
import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
|
|
37
45
|
import { TaskStore } from "./task-store";
|
|
38
46
|
|
|
39
47
|
const run = (root: string, args: string[]) => {
|
|
@@ -201,26 +209,22 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
|
|
|
201
209
|
operation === "git.commit" ||
|
|
202
210
|
operation === "git.push" ||
|
|
203
211
|
operation === "hosting.pull_request" ||
|
|
212
|
+
operation === "hosting.merge" ||
|
|
204
213
|
operation === "changelog.apply";
|
|
205
214
|
|
|
206
215
|
export const assertLocalExternalActionWriter = (
|
|
207
216
|
root: string,
|
|
208
217
|
caller: { host: Caller["host"]; actor: string },
|
|
209
218
|
paths: string[] = ["."],
|
|
210
|
-
): Result<
|
|
219
|
+
): Result<Owner> => {
|
|
211
220
|
const store = new TaskStore(root);
|
|
212
221
|
const listed = store.listTasks();
|
|
213
222
|
const workspace = store.readWorkspace();
|
|
214
223
|
if (!listed.ok) return listed as Result<never>;
|
|
215
224
|
if (!workspace.ok) return workspace as Result<never>;
|
|
216
225
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
217
|
-
const candidates = listed.data.filter(
|
|
218
|
-
(task)
|
|
219
|
-
task.status === "active" &&
|
|
220
|
-
task.workspaceId === workspace.data!.id &&
|
|
221
|
-
task.intent.provenance.session?.kind === "host" &&
|
|
222
|
-
task.intent.provenance.session.host === caller.host &&
|
|
223
|
-
task.intent.provenance.session.handle === caller.actor,
|
|
226
|
+
const candidates = listed.data.filter((task) =>
|
|
227
|
+
currentWriterOwnsTask(task, workspace.data!, caller),
|
|
224
228
|
);
|
|
225
229
|
if (candidates.length !== 1)
|
|
226
230
|
return failure("permission_denied", "local external action requires exactly one active task");
|
|
@@ -231,7 +235,7 @@ export const assertLocalExternalActionWriter = (
|
|
|
231
235
|
paths,
|
|
232
236
|
store,
|
|
233
237
|
});
|
|
234
|
-
return allowed
|
|
238
|
+
return allowed;
|
|
235
239
|
};
|
|
236
240
|
|
|
237
241
|
const safePath = (root: string, value: string): string | null => {
|
|
@@ -248,16 +252,29 @@ const branchCreationBaseline = (
|
|
|
248
252
|
root: string,
|
|
249
253
|
target: string,
|
|
250
254
|
base: string,
|
|
251
|
-
): {
|
|
255
|
+
): {
|
|
256
|
+
base_branch: string;
|
|
257
|
+
target_exists: boolean;
|
|
258
|
+
remote_base: string | null;
|
|
259
|
+
target_head: string | null;
|
|
260
|
+
} => {
|
|
252
261
|
const exists =
|
|
253
262
|
gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
|
|
254
|
-
if (exists)
|
|
263
|
+
if (exists)
|
|
264
|
+
return {
|
|
265
|
+
base_branch: base,
|
|
266
|
+
target_exists: true,
|
|
267
|
+
remote_base: null,
|
|
268
|
+
target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
|
|
269
|
+
};
|
|
255
270
|
const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
|
|
256
271
|
const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
|
|
272
|
+
const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null;
|
|
257
273
|
return {
|
|
258
274
|
base_branch: base,
|
|
259
275
|
target_exists: false,
|
|
260
|
-
remote_base:
|
|
276
|
+
remote_base: remoteBase,
|
|
277
|
+
target_head: remoteBase,
|
|
261
278
|
};
|
|
262
279
|
};
|
|
263
280
|
|
|
@@ -408,6 +425,9 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
408
425
|
try {
|
|
409
426
|
const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
|
|
410
427
|
if (
|
|
428
|
+
value.operation !== "git.branch_setup" &&
|
|
429
|
+
value.operation !== "git.commit" &&
|
|
430
|
+
value.operation !== "git.push" &&
|
|
411
431
|
value.operation !== "hosting.pull_request" &&
|
|
412
432
|
value.operation !== "hosting.merge" &&
|
|
413
433
|
value.operation !== "changelog.apply" &&
|
|
@@ -425,6 +445,28 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
425
445
|
if (!request.ok || !resolved || typeof resolved !== "object")
|
|
426
446
|
return failure("invalid_input", "approved hosting target is incomplete");
|
|
427
447
|
const target = resolved as Record<string, unknown>;
|
|
448
|
+
if (
|
|
449
|
+
value.operation === "git.commit" &&
|
|
450
|
+
(typeof target.head !== "string" ||
|
|
451
|
+
typeof target.staged !== "string" ||
|
|
452
|
+
typeof payload.message !== "string")
|
|
453
|
+
)
|
|
454
|
+
return failure("invalid_input", "approved Git commit target is incomplete");
|
|
455
|
+
if (
|
|
456
|
+
value.operation === "git.push" &&
|
|
457
|
+
(typeof target.branch !== "string" ||
|
|
458
|
+
typeof target.commit !== "string" ||
|
|
459
|
+
typeof target.remote !== "string")
|
|
460
|
+
)
|
|
461
|
+
return failure("invalid_input", "approved Git push target is incomplete");
|
|
462
|
+
if (
|
|
463
|
+
value.operation === "git.branch_setup" &&
|
|
464
|
+
(typeof target.head !== "string" ||
|
|
465
|
+
(payload.action === "reapply_stash"
|
|
466
|
+
? typeof target.stash_commit !== "string"
|
|
467
|
+
: typeof payload.target_branch !== "string" || typeof target.target_head !== "string"))
|
|
468
|
+
)
|
|
469
|
+
return failure("invalid_input", "approved Git branch target is incomplete");
|
|
428
470
|
if (
|
|
429
471
|
value.operation === "hosting.pull_request" &&
|
|
430
472
|
(typeof target.marker !== "string" ||
|
|
@@ -452,7 +494,11 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
452
494
|
)
|
|
453
495
|
return failure("invalid_input", "approved changelog target is incomplete");
|
|
454
496
|
if (
|
|
497
|
+
value.operation !== "git.branch_setup" &&
|
|
498
|
+
value.operation !== "git.commit" &&
|
|
499
|
+
value.operation !== "git.push" &&
|
|
455
500
|
value.operation !== "hosting.pull_request" &&
|
|
501
|
+
value.operation !== "hosting.merge" &&
|
|
456
502
|
value.operation !== "changelog.apply" &&
|
|
457
503
|
(typeof target.marker !== "string" ||
|
|
458
504
|
typeof target.baseUrl !== "string" ||
|
|
@@ -463,7 +509,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
|
|
|
463
509
|
return success(null, null, {
|
|
464
510
|
request: request.data,
|
|
465
511
|
descriptorPayload: value.payload,
|
|
466
|
-
marker:
|
|
512
|
+
...(typeof target.marker === "string" ? { marker: target.marker } : {}),
|
|
467
513
|
});
|
|
468
514
|
} catch {
|
|
469
515
|
return failure("invalid_input", "approved action descriptor is invalid");
|
|
@@ -499,8 +545,9 @@ export const readHostingAction = async (
|
|
|
499
545
|
resolved: ResolvedExternalAction,
|
|
500
546
|
actionRef: Ref,
|
|
501
547
|
): Promise<Result<HostingReadEvidence>> => {
|
|
502
|
-
|
|
503
|
-
|
|
548
|
+
const operation = resolved.request.operation;
|
|
549
|
+
if ((operation !== "hosting.pull_request" && operation !== "hosting.merge") || !resolved.marker)
|
|
550
|
+
return unknownHostingEvidence(operation);
|
|
504
551
|
try {
|
|
505
552
|
const cfg = vcsConfig("load", root);
|
|
506
553
|
if (
|
|
@@ -509,7 +556,7 @@ export const readHostingAction = async (
|
|
|
509
556
|
!cfg.provider ||
|
|
510
557
|
(cfg.provider !== "github" && cfg.provider !== "gitlab")
|
|
511
558
|
)
|
|
512
|
-
return unknownHostingEvidence(
|
|
559
|
+
return unknownHostingEvidence(operation);
|
|
513
560
|
const remote = pushRemote(root);
|
|
514
561
|
const repo = remote ? remoteRepo(remote) : null;
|
|
515
562
|
const approved = resolved.descriptorPayload as {
|
|
@@ -549,7 +596,7 @@ export const readHostingAction = async (
|
|
|
549
596
|
remoteProvider(approvedRemote) !== cfg.provider) ||
|
|
550
597
|
remoteParts(approvedRemote)?.host !== apiHost
|
|
551
598
|
)
|
|
552
|
-
return unknownHostingEvidence(
|
|
599
|
+
return unknownHostingEvidence(operation);
|
|
553
600
|
const token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
|
|
554
601
|
const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
|
|
555
602
|
const url =
|
|
@@ -559,15 +606,14 @@ export const readHostingAction = async (
|
|
|
559
606
|
const response = await fetch(url, {
|
|
560
607
|
headers: cfg.provider === "gitlab" ? { "PRIVATE-TOKEN": token } : headers,
|
|
561
608
|
});
|
|
562
|
-
if (!response.ok) return unknownHostingEvidence(
|
|
609
|
+
if (!response.ok) return unknownHostingEvidence(operation);
|
|
563
610
|
const values: unknown = await response.json();
|
|
564
611
|
// A full page is intentionally treated as truncated until pagination is added.
|
|
565
|
-
if (!Array.isArray(values) || values.length >= 100)
|
|
566
|
-
return unknownHostingEvidence("hosting.pull_request");
|
|
612
|
+
if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
|
|
567
613
|
const records: Record<string, unknown>[] = [];
|
|
568
614
|
for (const item of values) {
|
|
569
615
|
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
570
|
-
return unknownHostingEvidence(
|
|
616
|
+
return unknownHostingEvidence(operation);
|
|
571
617
|
const record = item as Record<string, unknown>;
|
|
572
618
|
const body = record.body ?? record.description;
|
|
573
619
|
const base = record.base;
|
|
@@ -609,35 +655,48 @@ export const readHostingAction = async (
|
|
|
609
655
|
typeof shaValue !== "string" ||
|
|
610
656
|
(typeof idValue !== "string" && typeof idValue !== "number")
|
|
611
657
|
)
|
|
612
|
-
return unknownHostingEvidence(
|
|
658
|
+
return unknownHostingEvidence(operation);
|
|
613
659
|
records.push(record);
|
|
614
660
|
}
|
|
615
|
-
const matches = records.filter((item)
|
|
616
|
-
const body = String(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
cfg.provider === "github" ? head?.ref : (item as Record<string, unknown>).source_branch;
|
|
627
|
-
const itemSha = cfg.provider === "github" ? head?.sha : (item as Record<string, unknown>).sha;
|
|
661
|
+
const matches = records.filter((item) => {
|
|
662
|
+
const body = String(item.body ?? item.description ?? "");
|
|
663
|
+
const base = item.base as Record<string, unknown> | undefined;
|
|
664
|
+
const head = item.head as Record<string, unknown> | undefined;
|
|
665
|
+
const itemTarget = cfg.provider === "github" ? base?.ref : item.target_branch;
|
|
666
|
+
const itemSource = cfg.provider === "github" ? head?.ref : item.source_branch;
|
|
667
|
+
const itemSha = cfg.provider === "github" ? head?.sha : item.sha;
|
|
668
|
+
const merged =
|
|
669
|
+
cfg.provider === "github"
|
|
670
|
+
? typeof item.merged_at === "string"
|
|
671
|
+
: item.state === "merged" || typeof item.merged_at === "string";
|
|
628
672
|
return (
|
|
629
|
-
body.includes(resolved.marker!) &&
|
|
673
|
+
(operation === "hosting.merge" ? merged : body.includes(resolved.marker!)) &&
|
|
630
674
|
itemTarget === target &&
|
|
631
675
|
itemSource === branch &&
|
|
632
676
|
itemSha === sourceCommit
|
|
633
677
|
);
|
|
634
678
|
});
|
|
635
|
-
if (
|
|
679
|
+
if (
|
|
680
|
+
operation === "hosting.merge" &&
|
|
681
|
+
records.some((item) => {
|
|
682
|
+
const base = item.base as Record<string, unknown> | undefined;
|
|
683
|
+
const head = item.head as Record<string, unknown> | undefined;
|
|
684
|
+
return (
|
|
685
|
+
(cfg.provider === "github" ? base?.ref : item.target_branch) === target &&
|
|
686
|
+
(cfg.provider === "github" ? head?.ref : item.source_branch) === branch &&
|
|
687
|
+
(cfg.provider === "github"
|
|
688
|
+
? typeof item.merged_at !== "string"
|
|
689
|
+
: item.state !== "merged" && typeof item.merged_at !== "string")
|
|
690
|
+
);
|
|
691
|
+
})
|
|
692
|
+
)
|
|
693
|
+
return unknownHostingEvidence(operation);
|
|
694
|
+
if (matches.length !== 1) return unknownHostingEvidence(operation);
|
|
636
695
|
const item = matches[0];
|
|
637
696
|
const id = item.number ?? item.iid ?? item.id;
|
|
638
|
-
if (typeof id !== "string" && typeof id !== "number")
|
|
639
|
-
return unknownHostingEvidence("hosting.pull_request");
|
|
697
|
+
if (typeof id !== "string" && typeof id !== "number") return unknownHostingEvidence(operation);
|
|
640
698
|
const evidenceDigest = sha256({
|
|
699
|
+
operation,
|
|
641
700
|
provider: cfg.provider,
|
|
642
701
|
id,
|
|
643
702
|
marker: resolved.marker,
|
|
@@ -658,7 +717,7 @@ export const readHostingAction = async (
|
|
|
658
717
|
data: { provider: cfg.provider, id },
|
|
659
718
|
});
|
|
660
719
|
} catch {
|
|
661
|
-
return unknownHostingEvidence(
|
|
720
|
+
return unknownHostingEvidence(operation);
|
|
662
721
|
}
|
|
663
722
|
};
|
|
664
723
|
|
|
@@ -812,14 +871,112 @@ export const readYouTrackAction = async (
|
|
|
812
871
|
}
|
|
813
872
|
};
|
|
814
873
|
|
|
874
|
+
export const readLocalAction = async (
|
|
875
|
+
root: string,
|
|
876
|
+
resolved: ResolvedExternalAction,
|
|
877
|
+
actionRef: Ref,
|
|
878
|
+
): Promise<Result<HostingReadEvidence>> => {
|
|
879
|
+
const operation = resolved.request.operation;
|
|
880
|
+
const target = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
|
|
881
|
+
if (!target) return unknownHostingEvidence(operation);
|
|
882
|
+
let observed: Record<string, unknown> | null = null;
|
|
883
|
+
if (operation === "git.commit") {
|
|
884
|
+
const commit = gitValue(root, ["rev-parse", "HEAD"]);
|
|
885
|
+
const parent = commit ? gitValue(root, ["rev-parse", `${commit}^`]) : null;
|
|
886
|
+
const message = commit ? gitValue(root, ["show", "-s", "--format=%B", commit]) : null;
|
|
887
|
+
const diff =
|
|
888
|
+
commit && parent
|
|
889
|
+
? gitRaw(root, ["diff", "--raw", "-z", "--no-ext-diff", "--no-textconv", parent, commit])
|
|
890
|
+
: null;
|
|
891
|
+
if (
|
|
892
|
+
commit &&
|
|
893
|
+
parent === target.head &&
|
|
894
|
+
message === resolved.request.payload.message &&
|
|
895
|
+
diff !== null &&
|
|
896
|
+
sha256(diff) === target.staged
|
|
897
|
+
)
|
|
898
|
+
observed = { commit, parent, message, staged: target.staged };
|
|
899
|
+
} else if (operation === "git.push") {
|
|
900
|
+
const branch = target.branch;
|
|
901
|
+
const commit = target.commit;
|
|
902
|
+
const approvedRemote = target.remote;
|
|
903
|
+
const currentRemote = pushRemote(root);
|
|
904
|
+
const currentIdentity = currentRemote ? credentialFreeRemote(currentRemote) : null;
|
|
905
|
+
if (
|
|
906
|
+
typeof branch === "string" &&
|
|
907
|
+
typeof commit === "string" &&
|
|
908
|
+
typeof approvedRemote === "string" &&
|
|
909
|
+
currentIdentity === approvedRemote
|
|
910
|
+
) {
|
|
911
|
+
const remote = run(root, ["ls-remote", currentRemote!, `refs/heads/${branch}`]);
|
|
912
|
+
const remoteCommit = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : null;
|
|
913
|
+
if (remoteCommit === commit) observed = { branch, commit, remote: approvedRemote };
|
|
914
|
+
}
|
|
915
|
+
} else if (operation === "git.branch_setup") {
|
|
916
|
+
const targetBranch = resolved.request.payload.target_branch;
|
|
917
|
+
const sdd = resolved.request.payload.sdd_dir;
|
|
918
|
+
if (resolved.request.payload.action === "reapply_stash" && typeof sdd === "string") {
|
|
919
|
+
try {
|
|
920
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
|
|
921
|
+
stash_ref?: unknown;
|
|
922
|
+
};
|
|
923
|
+
const stashes = gitRaw(root, ["stash", "list", "--format=%H"]);
|
|
924
|
+
if (
|
|
925
|
+
typeof target.stash_commit === "string" &&
|
|
926
|
+
manifest.stash_ref === undefined &&
|
|
927
|
+
stashes !== null &&
|
|
928
|
+
!stashes.split("\n").includes(target.stash_commit)
|
|
929
|
+
)
|
|
930
|
+
observed = { stash_commit: target.stash_commit };
|
|
931
|
+
} catch {}
|
|
932
|
+
} else if (
|
|
933
|
+
typeof targetBranch === "string" &&
|
|
934
|
+
typeof sdd === "string" &&
|
|
935
|
+
gitValue(root, ["branch", "--show-current"]) === targetBranch &&
|
|
936
|
+
typeof target.target_head === "string" &&
|
|
937
|
+
gitValue(root, ["rev-parse", "HEAD"]) === target.target_head
|
|
938
|
+
) {
|
|
939
|
+
try {
|
|
940
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
|
|
941
|
+
branch?: unknown;
|
|
942
|
+
};
|
|
943
|
+
if (manifest.branch === targetBranch) observed = { branch: targetBranch };
|
|
944
|
+
} catch {}
|
|
945
|
+
}
|
|
946
|
+
} else if (operation === "changelog.apply") {
|
|
947
|
+
const afterDigest = target.afterDigest;
|
|
948
|
+
const preview = changelogApplyPreview({ ...resolved.request.payload, workspace_root: root });
|
|
949
|
+
if (!preview.error && typeof afterDigest === "string" && preview.beforeDigest === afterDigest)
|
|
950
|
+
observed = { target: target.target, afterDigest };
|
|
951
|
+
}
|
|
952
|
+
if (!observed) return unknownHostingEvidence(operation);
|
|
953
|
+
const evidenceDigest = sha256({ operation, observed });
|
|
954
|
+
return success(null, null, {
|
|
955
|
+
outcome: "succeeded",
|
|
956
|
+
evidenceDigest,
|
|
957
|
+
observation: {
|
|
958
|
+
kind: "provider_read",
|
|
959
|
+
actionRef,
|
|
960
|
+
outcome: "succeeded",
|
|
961
|
+
evidenceDigest,
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
};
|
|
965
|
+
|
|
815
966
|
export const readExternalAction = async (
|
|
816
967
|
root: string,
|
|
817
968
|
resolved: ResolvedExternalAction,
|
|
818
969
|
actionRef: Ref,
|
|
819
970
|
): Promise<Result<HostingReadEvidence>> =>
|
|
820
|
-
resolved.request.operation === "
|
|
821
|
-
|
|
822
|
-
|
|
971
|
+
resolved.request.operation === "git.branch_setup" ||
|
|
972
|
+
resolved.request.operation === "git.commit" ||
|
|
973
|
+
resolved.request.operation === "git.push" ||
|
|
974
|
+
resolved.request.operation === "changelog.apply"
|
|
975
|
+
? readLocalAction(root, resolved, actionRef)
|
|
976
|
+
: resolved.request.operation === "hosting.pull_request" ||
|
|
977
|
+
resolved.request.operation === "hosting.merge"
|
|
978
|
+
? readHostingAction(root, resolved, actionRef)
|
|
979
|
+
: readYouTrackAction(root, resolved, actionRef);
|
|
823
980
|
|
|
824
981
|
const youTrackBase = (): string | null => {
|
|
825
982
|
try {
|
|
@@ -905,6 +1062,17 @@ export const resolveExternalActionRequest = (
|
|
|
905
1062
|
action === "reapply_stash" || !target_branch
|
|
906
1063
|
? { base_branch: policy.defaultTargetBranch }
|
|
907
1064
|
: branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
|
|
1065
|
+
let stashTarget: { stash_ref: string; stash_commit: string } | undefined;
|
|
1066
|
+
if (action === "reapply_stash") {
|
|
1067
|
+
try {
|
|
1068
|
+
const manifest = JSON.parse(
|
|
1069
|
+
fs.readFileSync(path.join(sdd_dir, "manifest.json"), "utf8"),
|
|
1070
|
+
) as { stash_ref?: unknown };
|
|
1071
|
+
const stash_ref = typeof manifest.stash_ref === "string" ? manifest.stash_ref : "";
|
|
1072
|
+
const stash_commit = stash_ref ? gitValue(root, ["rev-parse", stash_ref]) : null;
|
|
1073
|
+
if (stash_ref && stash_commit) stashTarget = { stash_ref, stash_commit };
|
|
1074
|
+
} catch {}
|
|
1075
|
+
}
|
|
908
1076
|
const normalized = {
|
|
909
1077
|
operation: request.operation,
|
|
910
1078
|
payload: {
|
|
@@ -919,7 +1087,7 @@ export const resolveExternalActionRequest = (
|
|
|
919
1087
|
request: normalized,
|
|
920
1088
|
descriptorPayload: {
|
|
921
1089
|
...normalized.payload,
|
|
922
|
-
resolved: { head, dirty, ...creation },
|
|
1090
|
+
resolved: { head, dirty, ...creation, ...stashTarget },
|
|
923
1091
|
},
|
|
924
1092
|
});
|
|
925
1093
|
}
|
|
@@ -1106,7 +1274,9 @@ export const resolveExternalActionRequest = (
|
|
|
1106
1274
|
outcome: "not_started",
|
|
1107
1275
|
fields: [{ path: "source_branch", reason: "protected branch" }],
|
|
1108
1276
|
});
|
|
1109
|
-
const source_commit =
|
|
1277
|
+
const source_commit = source_branch
|
|
1278
|
+
? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
|
|
1279
|
+
: null;
|
|
1110
1280
|
const remote = pushRemote(root);
|
|
1111
1281
|
const identity = remote ? credentialFreeRemote(remote) : null;
|
|
1112
1282
|
if (
|
|
@@ -1521,7 +1691,7 @@ export const executeResolvedExternalAction = async (
|
|
|
1521
1691
|
typeof resolvedDetails.resolved?.beforeExists === "boolean"
|
|
1522
1692
|
? resolvedDetails.resolved.beforeExists
|
|
1523
1693
|
: undefined;
|
|
1524
|
-
|
|
1694
|
+
const result = await executeConcreteExternalAction(
|
|
1525
1695
|
fresh.data.request,
|
|
1526
1696
|
root,
|
|
1527
1697
|
fresh.data.marker,
|
|
@@ -1532,4 +1702,13 @@ export const executeResolvedExternalAction = async (
|
|
|
1532
1702
|
beforeDigest,
|
|
1533
1703
|
beforeExists,
|
|
1534
1704
|
);
|
|
1705
|
+
if (!result.ok && result.code === "external_outcome_unknown" && caller) {
|
|
1706
|
+
const observed = await readLocalAction(root, fresh.data, {
|
|
1707
|
+
kind: "host",
|
|
1708
|
+
host: caller.host,
|
|
1709
|
+
handle: `local-recovery:${fresh.data.request.operation}`,
|
|
1710
|
+
});
|
|
1711
|
+
if (observed.ok) return success(null, null, { recovered: true });
|
|
1712
|
+
}
|
|
1713
|
+
return result;
|
|
1535
1714
|
};
|
|
@@ -18,6 +18,12 @@ import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
|
|
|
18
18
|
import * as z from "zod";
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
20
|
|
|
21
|
+
const chainStepSchema = z.union([
|
|
22
|
+
z.string().min(1),
|
|
23
|
+
z.object({ branch: z.string().min(1) }).strict(),
|
|
24
|
+
z.object({ pr: z.literal(true) }).strict(),
|
|
25
|
+
]);
|
|
26
|
+
|
|
21
27
|
export type AuthorizedActionInput = {
|
|
22
28
|
core: WorkitCore;
|
|
23
29
|
taskId: Id;
|
|
@@ -60,7 +66,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
60
66
|
payload: z
|
|
61
67
|
.object({
|
|
62
68
|
message: z.string().min(1).optional(),
|
|
63
|
-
plan_steps: z.array(
|
|
69
|
+
plan_steps: z.array(chainStepSchema).min(1).max(32).optional(),
|
|
64
70
|
plan_branch: z.string().min(1).optional(),
|
|
65
71
|
})
|
|
66
72
|
.strict(),
|
|
@@ -225,9 +231,26 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
|
|
|
225
231
|
: {}),
|
|
226
232
|
});
|
|
227
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Plan reservations never execute a git commit. A git.commit whose resolved
|
|
236
|
+
* payload carries a plan_steps array records the listed chain once and lets
|
|
237
|
+
* each listed single commit execute later under that authorization. Adapters
|
|
238
|
+
* must return plan success without running the commit effect, both for prior
|
|
239
|
+
* approvals and standing auto-approval; otherwise the reservation fails as a
|
|
240
|
+
* single commit requiring a message. Returns the listed count, else null.
|
|
241
|
+
*/
|
|
242
|
+
export const planReservationLength = (
|
|
243
|
+
operation: string,
|
|
244
|
+
descriptorPayload: unknown,
|
|
245
|
+
): number | null => {
|
|
246
|
+
if (operation !== "git.commit") return null;
|
|
247
|
+
const payload = (descriptorPayload ?? {}) as { plan_steps?: unknown };
|
|
248
|
+
return Array.isArray(payload.plan_steps) ? payload.plan_steps.length : null;
|
|
249
|
+
};
|
|
250
|
+
|
|
228
251
|
/** Compact host-facing help for the fixed optional-action surface. */
|
|
229
252
|
export const externalActionHelp =
|
|
230
|
-
"Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; hosting.merge {target_branch?,source_branch?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
|
|
253
|
+
"Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message?; plan_steps?: (string | {branch:string} | {pr:true})[]; plan_branch?}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; hosting.merge {target_branch?,source_branch?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind: git|pr|youtrack|github_issue|gitlab_issue|changelog|release|affected,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
|
|
231
254
|
|
|
232
255
|
export const externalActionRef = (
|
|
233
256
|
host: "opencode" | "pi" | "workit_cli",
|
|
@@ -251,14 +274,7 @@ export const externalActionState = (
|
|
|
251
274
|
return failure("storage_error", "external action state is unavailable");
|
|
252
275
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
253
276
|
const candidates = listed.data.flatMap((task) => {
|
|
254
|
-
if (
|
|
255
|
-
task.status !== "active" ||
|
|
256
|
-
task.workspaceId !== workspace.data!.id ||
|
|
257
|
-
task.intent.provenance.session?.kind !== "host" ||
|
|
258
|
-
task.intent.provenance.session.host !== host ||
|
|
259
|
-
task.intent.provenance.session.handle !== actor
|
|
260
|
-
)
|
|
261
|
-
return [];
|
|
277
|
+
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
262
278
|
return task.decisions
|
|
263
279
|
.filter(
|
|
264
280
|
(entry) =>
|
|
@@ -273,8 +289,8 @@ export const externalActionState = (
|
|
|
273
289
|
return failure(
|
|
274
290
|
"permission_denied",
|
|
275
291
|
candidates.length === 0
|
|
276
|
-
? "no approved action is bound to
|
|
277
|
-
: "multiple approved actions match
|
|
292
|
+
? "no approved action is bound to the active task"
|
|
293
|
+
: "multiple approved actions match the active task",
|
|
278
294
|
);
|
|
279
295
|
return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
|
|
280
296
|
};
|
|
@@ -414,12 +430,6 @@ export const approvedPlanCommit = (
|
|
|
414
430
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
415
431
|
const matches = listed.data.flatMap((task) => {
|
|
416
432
|
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
417
|
-
if (
|
|
418
|
-
task.intent.provenance.session?.kind !== "host" ||
|
|
419
|
-
task.intent.provenance.session.host !== host ||
|
|
420
|
-
task.intent.provenance.session.handle !== actor
|
|
421
|
-
)
|
|
422
|
-
return [];
|
|
423
433
|
return task.decisions.flatMap((entry) => {
|
|
424
434
|
const decision = entry.data;
|
|
425
435
|
if (
|
|
@@ -464,12 +474,7 @@ export type ChainStepQuery =
|
|
|
464
474
|
| { operation: "git.branch_setup"; target: string }
|
|
465
475
|
| { operation: "hosting.pull_request" };
|
|
466
476
|
|
|
467
|
-
/**
|
|
468
|
-
* Match one branch or PR step of a chain authorization: same session-bound
|
|
469
|
-
* search as plan commits, next unconsumed step of the right kind, lease
|
|
470
|
-
* intact. Commit steps stay on the plan path; this covers the steps plans
|
|
471
|
-
* could never express.
|
|
472
|
-
*/
|
|
477
|
+
/** Match the next branch or PR step of an active chain authorization. */
|
|
473
478
|
export const approvedChainStep = (
|
|
474
479
|
store: TaskStore,
|
|
475
480
|
host: string,
|
|
@@ -483,12 +488,6 @@ export const approvedChainStep = (
|
|
|
483
488
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
484
489
|
const matches = listed.data.flatMap((task) => {
|
|
485
490
|
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
486
|
-
if (
|
|
487
|
-
task.intent.provenance.session?.kind !== "host" ||
|
|
488
|
-
task.intent.provenance.session.host !== host ||
|
|
489
|
-
task.intent.provenance.session.handle !== actor
|
|
490
|
-
)
|
|
491
|
-
return [];
|
|
492
491
|
return task.decisions.flatMap((entry) => {
|
|
493
492
|
const decision = entry.data;
|
|
494
493
|
if (
|
|
@@ -734,21 +733,15 @@ export const approvedBranchSetupIntent = (
|
|
|
734
733
|
operation: string,
|
|
735
734
|
) => {
|
|
736
735
|
const current = branchSetupIntent(operation);
|
|
737
|
-
if (!current)
|
|
736
|
+
if (!current)
|
|
737
|
+
return failure("permission_denied", "no approved action is bound to the active task");
|
|
738
738
|
const listed = store.listTasks();
|
|
739
739
|
const workspace = store.readWorkspace();
|
|
740
740
|
if (!listed.ok || !workspace.ok)
|
|
741
741
|
return failure("storage_error", "external action state is unavailable");
|
|
742
742
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
743
743
|
const candidates = listed.data.flatMap((task) => {
|
|
744
|
-
if (
|
|
745
|
-
task.status !== "active" ||
|
|
746
|
-
task.workspaceId !== workspace.data!.id ||
|
|
747
|
-
task.intent.provenance.session?.kind !== "host" ||
|
|
748
|
-
task.intent.provenance.session.host !== host ||
|
|
749
|
-
task.intent.provenance.session.handle !== actor
|
|
750
|
-
)
|
|
751
|
-
return [];
|
|
744
|
+
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
752
745
|
return task.decisions
|
|
753
746
|
.filter(
|
|
754
747
|
(entry) =>
|
|
@@ -772,8 +765,8 @@ export const approvedBranchSetupIntent = (
|
|
|
772
765
|
return failure(
|
|
773
766
|
"permission_denied",
|
|
774
767
|
matches.length === 0
|
|
775
|
-
? "no approved action is bound to
|
|
776
|
-
: "multiple approved actions match
|
|
768
|
+
? "no approved action is bound to the active task"
|
|
769
|
+
: "multiple approved actions match the active task",
|
|
777
770
|
);
|
|
778
771
|
return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
|
|
779
772
|
};
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
export const SUPPORT_MATRIX = {
|
|
8
8
|
bun: "1.4.1",
|
|
9
9
|
node: { minimum: "24", current: "24.20.0" },
|
|
10
|
-
|
|
10
|
+
// The object-form dual entry (server() + setup()) only loads from
|
|
11
|
+
// 1.18.29+; the supported floor is 1.18.30 so no host can pass doctor while
|
|
12
|
+
// being unable to load the entry shape.
|
|
13
|
+
opencode: { minimum: "1.18.30", current: "1.18.30" },
|
|
11
14
|
// Codex is a qualification host, not a runtime dependency: the CLI version
|
|
12
15
|
// below is the one live qualification evidence covers. The doctor warns when
|
|
13
16
|
// an installed CLI drifts ahead so a fresh install never silently outruns
|
|
@@ -658,6 +658,7 @@ export const workerSchema = z
|
|
|
658
658
|
assignment: assignmentSchema,
|
|
659
659
|
state: z.enum(["assigned", "dispatching", "running", "cancelling", "stopped", "unknown"]),
|
|
660
660
|
session: refSchema.nullable(),
|
|
661
|
+
coordinator: refSchema.optional(),
|
|
661
662
|
report: workerReportSchema.nullable(),
|
|
662
663
|
})
|
|
663
664
|
.strict();
|
|
@@ -808,6 +809,8 @@ export const taskSummarySchema = z
|
|
|
808
809
|
})
|
|
809
810
|
.strict();
|
|
810
811
|
export type TaskSummary = z.infer<typeof taskSummarySchema>;
|
|
812
|
+
export const taskListItemSchema = taskSummarySchema.omit({ policy: true, requirements: true });
|
|
813
|
+
export type TaskListItem = z.infer<typeof taskListItemSchema>;
|
|
811
814
|
export const exportBundleSchema = z
|
|
812
815
|
.object({
|
|
813
816
|
schemaVersion: z.literal(1),
|
|
@@ -838,11 +841,15 @@ const taskOperations = {
|
|
|
838
841
|
expectedWorkspaceRevision: revision.nullable().optional(),
|
|
839
842
|
intent: intentSchema,
|
|
840
843
|
}),
|
|
841
|
-
list: operation({
|
|
844
|
+
list: operation({
|
|
845
|
+
action: z.literal("list"),
|
|
846
|
+
status: z.enum(["open", "closed", "all"]).optional(),
|
|
847
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
848
|
+
}),
|
|
842
849
|
inspect: operation({
|
|
843
850
|
action: z.literal("inspect"),
|
|
844
851
|
...taskId,
|
|
845
|
-
view: z.enum(["summary", "full"]),
|
|
852
|
+
view: z.enum(["summary", "full"]).optional(),
|
|
846
853
|
}),
|
|
847
854
|
revise: operation({
|
|
848
855
|
action: z.literal("revise"),
|
|
@@ -870,7 +877,7 @@ const taskOperations = {
|
|
|
870
877
|
...taskId,
|
|
871
878
|
...revisions,
|
|
872
879
|
expectedWorkspaceRevision: revision.optional(),
|
|
873
|
-
authorityRefs: z.array(refSchema),
|
|
880
|
+
authorityRefs: z.array(refSchema).optional(),
|
|
874
881
|
}),
|
|
875
882
|
close: operation({
|
|
876
883
|
action: z.literal("close"),
|
package/src/core/task-engine.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Result,
|
|
30
30
|
type Scope,
|
|
31
31
|
type TaskRecord,
|
|
32
|
+
type TaskListItem,
|
|
32
33
|
type TaskSummary,
|
|
33
34
|
type TaskView,
|
|
34
35
|
type Utc,
|
|
@@ -152,6 +153,7 @@ type WorkerAuthority = {
|
|
|
152
153
|
expectedWorkspaceRevision: string;
|
|
153
154
|
state: NativeWorkerObservation["state"];
|
|
154
155
|
session: NativeWorkerObservation["session"];
|
|
156
|
+
report: NativeWorkerObservation["report"] | null;
|
|
155
157
|
provenance: Provenance;
|
|
156
158
|
owner: object;
|
|
157
159
|
store: TaskStore;
|
|
@@ -280,6 +282,7 @@ const verifyNativeWorker = (
|
|
|
280
282
|
expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
|
|
281
283
|
state: expected.state,
|
|
282
284
|
session: expected.session,
|
|
285
|
+
report: expected.report ?? null,
|
|
283
286
|
provenance: result.data,
|
|
284
287
|
...binding,
|
|
285
288
|
});
|
|
@@ -305,7 +308,8 @@ const takeWorker = (
|
|
|
305
308
|
value.expectedRevision !== expected.expectedRevision ||
|
|
306
309
|
value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
|
|
307
310
|
value.state !== expected.state ||
|
|
308
|
-
!sameValue(value.session, expected.session)
|
|
311
|
+
!sameValue(value.session, expected.session) ||
|
|
312
|
+
!sameValue(value.report, expected.report ?? null)
|
|
309
313
|
)
|
|
310
314
|
return null;
|
|
311
315
|
return value;
|
|
@@ -361,6 +365,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
361
365
|
return failure("invalid_transition", "worker already has an observed session");
|
|
362
366
|
if (notStarted && !["dispatching", "cancelling"].includes(entry.data.state))
|
|
363
367
|
return failure("invalid_transition", "worker launch was not claimed");
|
|
368
|
+
if (input.report && input.state !== "stopped")
|
|
369
|
+
return failure("invalid_input", "worker reports require a stopped worker");
|
|
364
370
|
if (
|
|
365
371
|
(input.state === "running" ||
|
|
366
372
|
input.state === "cancelling" ||
|
|
@@ -383,6 +389,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
383
389
|
...entry.data,
|
|
384
390
|
state: terminalCancel ? ("cancelling" as const) : input.state,
|
|
385
391
|
session: input.session,
|
|
392
|
+
report: entry.data.report ?? input.report ?? null,
|
|
386
393
|
},
|
|
387
394
|
} satisfies Entry<Worker>;
|
|
388
395
|
const shouldClear =
|
|
@@ -402,6 +409,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
402
409
|
if (
|
|
403
410
|
entry.data.state === nextEntry.data.state &&
|
|
404
411
|
sameValue(entry.data.session, input.session) &&
|
|
412
|
+
sameValue(entry.data.report, nextEntry.data.report) &&
|
|
405
413
|
!shouldClear &&
|
|
406
414
|
!shouldUncertain
|
|
407
415
|
)
|
|
@@ -858,7 +866,7 @@ export class WorkitCore {
|
|
|
858
866
|
});
|
|
859
867
|
}
|
|
860
868
|
|
|
861
|
-
task(request: unknown): Result<TaskSummary |
|
|
869
|
+
task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
|
|
862
870
|
const root = this.contextRootError();
|
|
863
871
|
if (!root.ok) return root as Result<never>;
|
|
864
872
|
const parsed = parseOperation("task", request);
|
|
@@ -887,20 +895,43 @@ export class WorkitCore {
|
|
|
887
895
|
case "list": {
|
|
888
896
|
const listed = this.store.listTasks();
|
|
889
897
|
if (!listed.ok) return listed as Result<never>;
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
898
|
+
const status = input.status ?? "open";
|
|
899
|
+
const limit = input.limit ?? 20;
|
|
900
|
+
const selected = listed.data
|
|
901
|
+
.filter((task) =>
|
|
902
|
+
status === "all"
|
|
903
|
+
? true
|
|
904
|
+
: status === "closed"
|
|
905
|
+
? task.status === "closed"
|
|
906
|
+
: task.status !== "closed",
|
|
907
|
+
)
|
|
908
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
909
|
+
.slice(0, limit);
|
|
910
|
+
if (selected.length === 0) return success(null, null, []);
|
|
911
|
+
const workspace = this.store.readWorkspace();
|
|
912
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
913
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
914
|
+
const tasks = selected.map((task): TaskListItem => ({
|
|
915
|
+
id: task.id,
|
|
916
|
+
revision: task.revision,
|
|
917
|
+
workspaceRevision: workspace.data!.revision,
|
|
918
|
+
createdAt: task.createdAt,
|
|
919
|
+
updatedAt: task.updatedAt,
|
|
920
|
+
runtime: task.runtime ?? null,
|
|
921
|
+
objective: task.intent.data.objective,
|
|
922
|
+
status: task.status,
|
|
923
|
+
closure: task.closure,
|
|
924
|
+
progress: task.progress,
|
|
925
|
+
writer: task.status === "closed" ? null : workspace.data!.writer,
|
|
926
|
+
}));
|
|
927
|
+
return success(null, null, tasks);
|
|
897
928
|
}
|
|
898
929
|
case "inspect": {
|
|
899
930
|
const task = this.store.readTask(input.taskId);
|
|
900
931
|
if (!task.ok) return task as Result<never>;
|
|
901
932
|
const helper = this.helperTaskGuard(task.data, true);
|
|
902
933
|
if (!helper.ok) return helper as Result<never>;
|
|
903
|
-
if (input.view === "summary") return this.summary(task.data);
|
|
934
|
+
if ((input.view ?? "summary") === "summary") return this.summary(task.data);
|
|
904
935
|
return this.view(task.data);
|
|
905
936
|
}
|
|
906
937
|
case "pause":
|
|
@@ -1344,11 +1375,14 @@ export class WorkitCore {
|
|
|
1344
1375
|
const evaluations = evaluateEvidence(task.data, current.data);
|
|
1345
1376
|
const verified = evidence.some((entry) => {
|
|
1346
1377
|
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1347
|
-
return
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1378
|
+
return (
|
|
1379
|
+
entry.recordedAt >= finding.recordedAt &&
|
|
1380
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1381
|
+
kind: entry.data.kind,
|
|
1382
|
+
candidateId: entry.data.candidateId,
|
|
1383
|
+
status: evaluation?.status ?? "missing",
|
|
1384
|
+
})
|
|
1385
|
+
);
|
|
1352
1386
|
});
|
|
1353
1387
|
if (!verified)
|
|
1354
1388
|
return failure("permission_denied", "fixed findings require passing verification evidence");
|
|
@@ -1451,7 +1485,18 @@ export class WorkitCore {
|
|
|
1451
1485
|
if (task.data.status !== "active")
|
|
1452
1486
|
return failure("invalid_transition", "paused or closed tasks cannot assign workers");
|
|
1453
1487
|
if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
|
|
1454
|
-
return failure("permission_denied", "worker assignment is outside task scope"
|
|
1488
|
+
return failure("permission_denied", "worker assignment is outside task scope", {
|
|
1489
|
+
fields: [
|
|
1490
|
+
{
|
|
1491
|
+
path: "assignment.scope",
|
|
1492
|
+
reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
path: "task.intent.scope",
|
|
1496
|
+
reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1497
|
+
},
|
|
1498
|
+
],
|
|
1499
|
+
});
|
|
1455
1500
|
if (
|
|
1456
1501
|
input.assignment.decisionIds.some(
|
|
1457
1502
|
(id: string) => !task.data.decisions.some((entry) => entry.id === id),
|
|
@@ -1739,7 +1784,15 @@ export class WorkitCore {
|
|
|
1739
1784
|
...item,
|
|
1740
1785
|
recordedAt: mutation.now,
|
|
1741
1786
|
provenance: attested.data,
|
|
1742
|
-
data: {
|
|
1787
|
+
data: {
|
|
1788
|
+
...item.data,
|
|
1789
|
+
state: "dispatching" as const,
|
|
1790
|
+
coordinator: {
|
|
1791
|
+
kind: "host" as const,
|
|
1792
|
+
host: this.context.caller.host,
|
|
1793
|
+
handle: this.context.caller.actor,
|
|
1794
|
+
},
|
|
1795
|
+
},
|
|
1743
1796
|
}
|
|
1744
1797
|
: item,
|
|
1745
1798
|
),
|
|
@@ -1828,6 +1881,7 @@ export class WorkitCore {
|
|
|
1828
1881
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1829
1882
|
state: "stopped",
|
|
1830
1883
|
session: null,
|
|
1884
|
+
report: null,
|
|
1831
1885
|
provenance: attested.data,
|
|
1832
1886
|
owner: this.authorityOwner,
|
|
1833
1887
|
store: this.store,
|
|
@@ -2205,11 +2259,16 @@ export class WorkitCore {
|
|
|
2205
2259
|
const workspace = this.store.readWorkspace();
|
|
2206
2260
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2207
2261
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2208
|
-
const
|
|
2262
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
2263
|
+
const current = historical
|
|
2264
|
+
? success(null, null, historical)
|
|
2265
|
+
: captureCandidate(this.store.root, task.intent.data.scope, environment());
|
|
2209
2266
|
if (!current.ok) return current as Result<never>;
|
|
2267
|
+
const evaluationWorkspace =
|
|
2268
|
+
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2210
2269
|
const requirements = evaluateRequirements(
|
|
2211
2270
|
task,
|
|
2212
|
-
|
|
2271
|
+
evaluationWorkspace,
|
|
2213
2272
|
this.context.capabilities,
|
|
2214
2273
|
current.data,
|
|
2215
2274
|
this.store.root,
|
|
@@ -2228,7 +2287,7 @@ export class WorkitCore {
|
|
|
2228
2287
|
progress: task.progress,
|
|
2229
2288
|
policy: task.policy,
|
|
2230
2289
|
requirements,
|
|
2231
|
-
writer:
|
|
2290
|
+
writer: evaluationWorkspace.writer,
|
|
2232
2291
|
});
|
|
2233
2292
|
}
|
|
2234
2293
|
|
|
@@ -2236,11 +2295,16 @@ export class WorkitCore {
|
|
|
2236
2295
|
const workspace = this.store.readWorkspace();
|
|
2237
2296
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2238
2297
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2239
|
-
const
|
|
2298
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
2299
|
+
const current = historical
|
|
2300
|
+
? success(null, null, historical)
|
|
2301
|
+
: captureCandidate(this.store.root, task.intent.data.scope, environment());
|
|
2240
2302
|
if (!current.ok) return current as Result<never>;
|
|
2303
|
+
const evaluationWorkspace =
|
|
2304
|
+
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2241
2305
|
const requirements = evaluateRequirements(
|
|
2242
2306
|
task,
|
|
2243
|
-
|
|
2307
|
+
evaluationWorkspace,
|
|
2244
2308
|
this.context.capabilities,
|
|
2245
2309
|
current.data,
|
|
2246
2310
|
this.store.root,
|
|
@@ -2248,7 +2312,7 @@ export class WorkitCore {
|
|
|
2248
2312
|
);
|
|
2249
2313
|
return success(task.revision, workspace.data.revision, {
|
|
2250
2314
|
task,
|
|
2251
|
-
workspace:
|
|
2315
|
+
workspace: evaluationWorkspace,
|
|
2252
2316
|
capabilities: this.context.capabilities,
|
|
2253
2317
|
requirements,
|
|
2254
2318
|
evidence: evaluateEvidence(task, current.data),
|
|
@@ -2357,8 +2421,6 @@ export class WorkitCore {
|
|
|
2357
2421
|
(status === "active" && task.data.status !== "paused")
|
|
2358
2422
|
)
|
|
2359
2423
|
return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
|
|
2360
|
-
if (status === "active" && input.authorityRefs.length === 0)
|
|
2361
|
-
return failure("permission_denied", "resuming a task requires authority references");
|
|
2362
2424
|
const workspace = this.store.readWorkspace();
|
|
2363
2425
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2364
2426
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
@@ -2403,7 +2465,7 @@ export class WorkitCore {
|
|
|
2403
2465
|
)
|
|
2404
2466
|
)
|
|
2405
2467
|
return failure("recovery_required", "imported task requires resume reconciliation");
|
|
2406
|
-
if (!validResumeApproval(task.data, workspace.data, input.authorityRefs, this.context))
|
|
2468
|
+
if (!validResumeApproval(task.data, workspace.data, input.authorityRefs ?? [], this.context))
|
|
2407
2469
|
return failure("permission_denied", "imported resume requires native destination approval");
|
|
2408
2470
|
resumeCandidate = current.data.candidate;
|
|
2409
2471
|
}
|
|
@@ -2552,9 +2614,7 @@ export class WorkitCore {
|
|
|
2552
2614
|
success(context.revision, null, {
|
|
2553
2615
|
...value,
|
|
2554
2616
|
status: "closed",
|
|
2555
|
-
candidates:
|
|
2556
|
-
? value.candidates
|
|
2557
|
-
: [...value.candidates, withCandidate],
|
|
2617
|
+
candidates: taskForView.candidates,
|
|
2558
2618
|
closure: {
|
|
2559
2619
|
outcome: closure.data.outcome,
|
|
2560
2620
|
at: context.now,
|
|
@@ -412,17 +412,15 @@ export const resolveBinding = (
|
|
|
412
412
|
};
|
|
413
413
|
};
|
|
414
414
|
|
|
415
|
-
/**
|
|
416
|
-
*
|
|
417
|
-
* bound to the finding's candidate. Used by the auto-reopen scan, the
|
|
418
|
-
* resolve gate, and closure — one predicate, one error code each. */
|
|
415
|
+
/** Evidence evaluation establishes freshness against the current tree;
|
|
416
|
+
* the finding's original candidate identifies where the defect was found. */
|
|
419
417
|
export const findingVerificationPasses = (
|
|
420
418
|
findingCandidateId: string | null | undefined,
|
|
421
419
|
evidence: { kind: string; candidateId: string | null | undefined; status: string },
|
|
422
420
|
): boolean =>
|
|
423
421
|
evidence.status === "passed" &&
|
|
424
422
|
(evidence.kind === "check" || evidence.kind === "review") &&
|
|
425
|
-
((findingCandidateId ?? null) === null || evidence.candidateId
|
|
423
|
+
((findingCandidateId ?? null) === null || evidence.candidateId != null);
|
|
426
424
|
const applicableDecision = (
|
|
427
425
|
task: TaskRecord,
|
|
428
426
|
workspace: WorkspaceRecord,
|
package/src/core/workers.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Revision,
|
|
12
12
|
type TaskRecord,
|
|
13
13
|
type Worker,
|
|
14
|
+
type WorkerReport,
|
|
14
15
|
type WorkspaceRecord,
|
|
15
16
|
} from "./task-contract";
|
|
16
17
|
import { TaskStore } from "./task-store";
|
|
@@ -31,6 +32,7 @@ export type NativeWorkerObservation = {
|
|
|
31
32
|
expectedWorkspaceRevision: Revision;
|
|
32
33
|
state: WorkerState;
|
|
33
34
|
session: HostSession | null;
|
|
35
|
+
report?: WorkerReport;
|
|
34
36
|
observation: unknown;
|
|
35
37
|
};
|
|
36
38
|
|
|
@@ -132,6 +134,21 @@ export type ProductWriteInput = {
|
|
|
132
134
|
store?: TaskStore;
|
|
133
135
|
};
|
|
134
136
|
|
|
137
|
+
export const currentWriterOwnsTask = (
|
|
138
|
+
task: TaskRecord,
|
|
139
|
+
workspace: WorkspaceRecord,
|
|
140
|
+
caller: { host: string; actor: string },
|
|
141
|
+
): boolean => {
|
|
142
|
+
const owner = workspace.writer?.owner;
|
|
143
|
+
return (
|
|
144
|
+
task.status === "active" &&
|
|
145
|
+
task.workspaceId === workspace.id &&
|
|
146
|
+
owner?.taskId === task.id &&
|
|
147
|
+
owner.session.host === caller.host &&
|
|
148
|
+
owner.session.handle === caller.actor
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
135
152
|
/** Check the authoritative workspace owner and the caller's role. File paths
|
|
136
153
|
* are host-policy territory: only malformed paths are rejected here, never
|
|
137
154
|
* out-of-scope ones. */
|