@brainervirus/workit-core 1.2.1 → 1.3.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/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +214 -63
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +7 -1
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
|
@@ -13,10 +13,17 @@ import {
|
|
|
13
13
|
sha256,
|
|
14
14
|
} from "./task-contract";
|
|
15
15
|
import { WorkitCore } from "./task-engine";
|
|
16
|
-
import { TaskStore } from "./task-store";
|
|
17
|
-
import {
|
|
16
|
+
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
17
|
+
import {
|
|
18
|
+
chainStepKey,
|
|
19
|
+
normalizeChainSteps,
|
|
20
|
+
type ActionReservation,
|
|
21
|
+
type ChainStep,
|
|
22
|
+
} from "./authority";
|
|
18
23
|
import * as z from "zod";
|
|
19
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { realpathSync } from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
20
27
|
|
|
21
28
|
const chainStepSchema = z.union([
|
|
22
29
|
z.string().min(1),
|
|
@@ -45,6 +52,9 @@ export type AuthorizedActionInput = {
|
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
export type ExternalActionResult<T> = Result<T>;
|
|
55
|
+
type WithExternalActionLock = <T>(
|
|
56
|
+
operation: () => Promise<ExternalActionResult<T>>,
|
|
57
|
+
) => Promise<ExternalActionResult<T>>;
|
|
48
58
|
|
|
49
59
|
const externalActionSchema = z.discriminatedUnion("operation", [
|
|
50
60
|
z
|
|
@@ -56,6 +66,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
56
66
|
sdd_dir: z.string().optional(),
|
|
57
67
|
target_branch: z.string().optional(),
|
|
58
68
|
stash: z.enum(["yes", "no"]).optional(),
|
|
69
|
+
cwd: z.string().min(1).optional(),
|
|
59
70
|
})
|
|
60
71
|
.strict(),
|
|
61
72
|
})
|
|
@@ -68,6 +79,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
68
79
|
message: z.string().min(1).optional(),
|
|
69
80
|
plan_steps: z.array(chainStepSchema).min(1).max(32).optional(),
|
|
70
81
|
plan_branch: z.string().min(1).optional(),
|
|
82
|
+
cwd: z.string().min(1).optional(),
|
|
71
83
|
})
|
|
72
84
|
.strict(),
|
|
73
85
|
})
|
|
@@ -75,7 +87,9 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
75
87
|
z
|
|
76
88
|
.object({
|
|
77
89
|
operation: z.literal("git.push"),
|
|
78
|
-
payload: z
|
|
90
|
+
payload: z
|
|
91
|
+
.object({ branch: z.string().min(1).optional(), cwd: z.string().min(1).optional() })
|
|
92
|
+
.strict(),
|
|
79
93
|
})
|
|
80
94
|
.strict(),
|
|
81
95
|
z
|
|
@@ -88,6 +102,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
88
102
|
draft: z.boolean().optional(),
|
|
89
103
|
target_branch: z.string().optional(),
|
|
90
104
|
babysit: z.boolean().optional(),
|
|
105
|
+
cwd: z.string().min(1).optional(),
|
|
91
106
|
})
|
|
92
107
|
.strict(),
|
|
93
108
|
})
|
|
@@ -99,10 +114,17 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
99
114
|
.object({
|
|
100
115
|
target_branch: z.string().min(1).optional(),
|
|
101
116
|
source_branch: z.string().min(1).optional(),
|
|
117
|
+
cwd: z.string().min(1).optional(),
|
|
102
118
|
})
|
|
103
119
|
.strict(),
|
|
104
120
|
})
|
|
105
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(),
|
|
106
128
|
z
|
|
107
129
|
.object({
|
|
108
130
|
operation: z.literal("youtrack.update"),
|
|
@@ -179,6 +201,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
179
201
|
mode: z.string().optional(),
|
|
180
202
|
specPath: z.string().optional(),
|
|
181
203
|
planPath: z.string().optional(),
|
|
204
|
+
cwd: z.string().min(1).optional(),
|
|
182
205
|
})
|
|
183
206
|
.strict(),
|
|
184
207
|
})
|
|
@@ -205,7 +228,10 @@ export type ExternalActionRunner = <T>(
|
|
|
205
228
|
) => Promise<ExternalActionResult<T>>;
|
|
206
229
|
export type ExternalActionEffect = <T>(
|
|
207
230
|
operation: string,
|
|
208
|
-
effect: (
|
|
231
|
+
effect: (
|
|
232
|
+
step?: string,
|
|
233
|
+
reservation?: ActionReservation,
|
|
234
|
+
) => T | Result<T> | Promise<T | Result<T>>,
|
|
209
235
|
) => Promise<ExternalActionResult<T>>;
|
|
210
236
|
export type ExternalActionBinding = (
|
|
211
237
|
operation: string,
|
|
@@ -250,7 +276,7 @@ export const planReservationLength = (
|
|
|
250
276
|
|
|
251
277
|
/** Compact host-facing help for the fixed optional-action surface. */
|
|
252
278
|
export const externalActionHelp =
|
|
253
|
-
"Fixed actions:
|
|
279
|
+
"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).";
|
|
254
280
|
|
|
255
281
|
export const externalActionRef = (
|
|
256
282
|
host: "opencode" | "pi" | "workit_cli",
|
|
@@ -351,13 +377,14 @@ export const priorExternalAction = (
|
|
|
351
377
|
/** Parse a plan-commit authorization descriptor (one approved list per plan). */
|
|
352
378
|
export const planCommitDescriptor = (
|
|
353
379
|
content: string,
|
|
354
|
-
): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
|
|
380
|
+
): { steps: ChainStep[]; branch: string; snapshotHead: string | null; cwd?: string } | null => {
|
|
355
381
|
try {
|
|
356
382
|
const value = JSON.parse(content) as {
|
|
357
383
|
operation?: unknown;
|
|
358
384
|
payload?: {
|
|
359
385
|
plan_steps?: unknown;
|
|
360
386
|
plan_branch?: unknown;
|
|
387
|
+
cwd?: unknown;
|
|
361
388
|
resolved?: { head?: unknown; branch?: unknown };
|
|
362
389
|
};
|
|
363
390
|
};
|
|
@@ -365,8 +392,15 @@ export const planCommitDescriptor = (
|
|
|
365
392
|
const steps = normalizeChainSteps(value.payload.plan_steps);
|
|
366
393
|
const branch = value.payload.plan_branch;
|
|
367
394
|
if (!steps || typeof branch !== "string" || !branch) return null;
|
|
395
|
+
const cwd = value.payload.cwd;
|
|
396
|
+
if (cwd !== undefined && (typeof cwd !== "string" || !path.isAbsolute(cwd))) return null;
|
|
368
397
|
const head = value.payload.resolved?.head;
|
|
369
|
-
return {
|
|
398
|
+
return {
|
|
399
|
+
steps,
|
|
400
|
+
branch,
|
|
401
|
+
snapshotHead: typeof head === "string" ? head : null,
|
|
402
|
+
...(typeof cwd === "string" ? { cwd } : {}),
|
|
403
|
+
};
|
|
370
404
|
} catch {
|
|
371
405
|
return null;
|
|
372
406
|
}
|
|
@@ -377,17 +411,21 @@ export const planCommitDescriptor = (
|
|
|
377
411
|
* HEAD — history only moved forward. Rebase/reset underneath an approved
|
|
378
412
|
* chain invalidates it instead of executing on rewritten history.
|
|
379
413
|
*/
|
|
380
|
-
export const chainLeaseValid = (
|
|
414
|
+
export const chainLeaseValid = (
|
|
415
|
+
store: TaskStore,
|
|
416
|
+
snapshotHead: string | null,
|
|
417
|
+
targetRoot = store.root,
|
|
418
|
+
): boolean => {
|
|
381
419
|
if (!snapshotHead) return true;
|
|
382
420
|
try {
|
|
383
421
|
const head = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
384
|
-
cwd:
|
|
422
|
+
cwd: targetRoot,
|
|
385
423
|
encoding: "utf8",
|
|
386
424
|
stdio: ["pipe", "pipe", "pipe"],
|
|
387
425
|
}).trim();
|
|
388
426
|
if (head === snapshotHead) return true;
|
|
389
427
|
execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
|
|
390
|
-
cwd:
|
|
428
|
+
cwd: targetRoot,
|
|
391
429
|
encoding: "utf8",
|
|
392
430
|
stdio: ["pipe", "pipe", "pipe"],
|
|
393
431
|
});
|
|
@@ -420,6 +458,7 @@ export const approvedPlanCommit = (
|
|
|
420
458
|
host: string,
|
|
421
459
|
actor: string,
|
|
422
460
|
message: string,
|
|
461
|
+
targetRoot = store.root,
|
|
423
462
|
): Result<PlanCommitAuthorization> => {
|
|
424
463
|
if (typeof message !== "string" || !message)
|
|
425
464
|
return failure("invalid_input", "commit message is required");
|
|
@@ -442,12 +481,13 @@ export const approvedPlanCommit = (
|
|
|
442
481
|
return [];
|
|
443
482
|
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
444
483
|
if (!plan) return [];
|
|
484
|
+
if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
|
|
445
485
|
const completed =
|
|
446
486
|
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
447
487
|
[];
|
|
448
488
|
const next = plan.steps[completed.length];
|
|
449
489
|
if (!next || next.kind !== "commit" || next.message !== message) return [];
|
|
450
|
-
if (!chainLeaseValid(store, plan.snapshotHead)) return [];
|
|
490
|
+
if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
|
|
451
491
|
return [
|
|
452
492
|
{
|
|
453
493
|
task,
|
|
@@ -471,8 +511,8 @@ export const approvedPlanCommit = (
|
|
|
471
511
|
};
|
|
472
512
|
|
|
473
513
|
export type ChainStepQuery =
|
|
474
|
-
| { operation: "git.branch_setup"; target: string }
|
|
475
|
-
| { operation: "hosting.pull_request" };
|
|
514
|
+
| { operation: "git.branch_setup"; target: string; cwd?: string }
|
|
515
|
+
| { operation: "hosting.pull_request"; cwd?: string };
|
|
476
516
|
|
|
477
517
|
/** Match the next branch or PR step of an active chain authorization. */
|
|
478
518
|
export const approvedChainStep = (
|
|
@@ -500,6 +540,8 @@ export const approvedChainStep = (
|
|
|
500
540
|
return [];
|
|
501
541
|
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
502
542
|
if (!plan) return [];
|
|
543
|
+
const targetRoot = query.cwd ?? store.root;
|
|
544
|
+
if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
|
|
503
545
|
const completed =
|
|
504
546
|
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
505
547
|
[];
|
|
@@ -510,7 +552,7 @@ export const approvedChainStep = (
|
|
|
510
552
|
} else if (next.kind !== "pr") {
|
|
511
553
|
return [];
|
|
512
554
|
}
|
|
513
|
-
if (!chainLeaseValid(store, plan.snapshotHead)) return [];
|
|
555
|
+
if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
|
|
514
556
|
return [
|
|
515
557
|
{
|
|
516
558
|
task,
|
|
@@ -547,6 +589,17 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
|
|
|
547
589
|
}
|
|
548
590
|
};
|
|
549
591
|
|
|
592
|
+
const descriptorTargetRoot = (store: TaskStore, operation: string): string | null => {
|
|
593
|
+
try {
|
|
594
|
+
const descriptor = JSON.parse(operation) as { payload?: { cwd?: unknown } };
|
|
595
|
+
const cwd = descriptor.payload?.cwd;
|
|
596
|
+
if (cwd === undefined) return store.root;
|
|
597
|
+
return typeof cwd === "string" && path.isAbsolute(cwd) ? realpathSync(cwd) : null;
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
550
603
|
/** Resolve a chain authorization into the exact binding an adapter needs
|
|
551
604
|
* to run one branch or PR step under the approved chain. Commit steps stay
|
|
552
605
|
* on the plan path; this covers the steps plans could never express. */
|
|
@@ -572,12 +625,18 @@ export const chainStepBinding = (
|
|
|
572
625
|
} catch {
|
|
573
626
|
return null;
|
|
574
627
|
}
|
|
628
|
+
const targetRoot = descriptorTargetRoot(store, operation);
|
|
629
|
+
if (!targetRoot) return null;
|
|
575
630
|
const query: ChainStepQuery | null =
|
|
576
631
|
descriptor.operation === "git.branch_setup" &&
|
|
577
632
|
typeof descriptor.payload?.target_branch === "string"
|
|
578
|
-
? {
|
|
633
|
+
? {
|
|
634
|
+
operation: "git.branch_setup",
|
|
635
|
+
target: descriptor.payload.target_branch as string,
|
|
636
|
+
cwd: targetRoot,
|
|
637
|
+
}
|
|
579
638
|
: descriptor.operation === "hosting.pull_request"
|
|
580
|
-
? { operation: "hosting.pull_request" }
|
|
639
|
+
? { operation: "hosting.pull_request", cwd: targetRoot }
|
|
581
640
|
: null;
|
|
582
641
|
if (!query) return null;
|
|
583
642
|
const chain = approvedChainStep(store, host, actor, query);
|
|
@@ -587,7 +646,7 @@ export const chainStepBinding = (
|
|
|
587
646
|
let branch: string;
|
|
588
647
|
try {
|
|
589
648
|
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
590
|
-
cwd:
|
|
649
|
+
cwd: targetRoot,
|
|
591
650
|
encoding: "utf8",
|
|
592
651
|
stdio: ["pipe", "pipe", "pipe"],
|
|
593
652
|
}).trim();
|
|
@@ -627,12 +686,14 @@ export const planCommitBinding = (
|
|
|
627
686
|
} | null => {
|
|
628
687
|
const message = commitMessageFromDescriptor(operation);
|
|
629
688
|
if (!message) return null;
|
|
630
|
-
const
|
|
689
|
+
const targetRoot = descriptorTargetRoot(store, operation);
|
|
690
|
+
if (!targetRoot) return null;
|
|
691
|
+
const plan = approvedPlanCommit(store, host, actor, message, targetRoot);
|
|
631
692
|
if (!plan.ok) return null;
|
|
632
693
|
let branch: string;
|
|
633
694
|
try {
|
|
634
695
|
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
635
|
-
cwd:
|
|
696
|
+
cwd: targetRoot,
|
|
636
697
|
encoding: "utf8",
|
|
637
698
|
stdio: ["pipe", "pipe", "pipe"],
|
|
638
699
|
}).trim();
|
|
@@ -691,18 +752,26 @@ export const approvedExternalAction = (
|
|
|
691
752
|
return selected;
|
|
692
753
|
};
|
|
693
754
|
|
|
694
|
-
/** Intent identity for
|
|
695
|
-
* effect; head and dirt are execution-time state that re-resolves. */
|
|
755
|
+
/** Intent identity for branch setup includes the exact remote and local base commits. */
|
|
696
756
|
export const branchSetupIntent = (
|
|
697
757
|
descriptor: string,
|
|
698
|
-
): {
|
|
758
|
+
): {
|
|
759
|
+
target: string;
|
|
760
|
+
base: string;
|
|
761
|
+
localBase: string | null;
|
|
762
|
+
remoteBase: string | null;
|
|
763
|
+
targetExists: boolean;
|
|
764
|
+
cwd?: string;
|
|
765
|
+
} | null => {
|
|
699
766
|
try {
|
|
700
767
|
const value = JSON.parse(descriptor) as {
|
|
701
768
|
operation?: unknown;
|
|
702
769
|
payload?: {
|
|
703
770
|
target_branch?: unknown;
|
|
771
|
+
cwd?: unknown;
|
|
704
772
|
resolved?: {
|
|
705
773
|
base_branch?: unknown;
|
|
774
|
+
local_base?: unknown;
|
|
706
775
|
remote_base?: unknown;
|
|
707
776
|
target_exists?: unknown;
|
|
708
777
|
};
|
|
@@ -711,13 +780,20 @@ export const branchSetupIntent = (
|
|
|
711
780
|
if (value.operation !== "git.branch_setup" || !value.payload) return null;
|
|
712
781
|
const { target_branch, resolved } = value.payload;
|
|
713
782
|
if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
|
|
783
|
+
if (
|
|
784
|
+
value.payload.cwd !== undefined &&
|
|
785
|
+
(typeof value.payload.cwd !== "string" || !path.isAbsolute(value.payload.cwd))
|
|
786
|
+
)
|
|
787
|
+
return null;
|
|
714
788
|
if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
|
|
715
789
|
return null;
|
|
716
790
|
return {
|
|
717
791
|
target: target_branch,
|
|
718
792
|
base: resolved.base_branch,
|
|
793
|
+
localBase: typeof resolved.local_base === "string" ? resolved.local_base : null,
|
|
719
794
|
remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
|
|
720
795
|
targetExists: resolved.target_exists,
|
|
796
|
+
...(typeof value.payload.cwd === "string" ? { cwd: value.payload.cwd } : {}),
|
|
721
797
|
};
|
|
722
798
|
} catch {
|
|
723
799
|
return null;
|
|
@@ -755,8 +831,10 @@ export const approvedBranchSetupIntent = (
|
|
|
755
831
|
const intent = branchSetupIntent(entry.data.binding.approvedContent);
|
|
756
832
|
return (
|
|
757
833
|
intent !== null &&
|
|
834
|
+
(intent.cwd ?? store.root) === (current.cwd ?? store.root) &&
|
|
758
835
|
intent.target === current.target &&
|
|
759
836
|
intent.base === current.base &&
|
|
837
|
+
intent.localBase === current.localBase &&
|
|
760
838
|
intent.remoteBase === current.remoteBase &&
|
|
761
839
|
intent.targetExists === current.targetExists
|
|
762
840
|
);
|
|
@@ -820,9 +898,11 @@ export const priorResolvedDrift = (
|
|
|
820
898
|
? "base_branch"
|
|
821
899
|
: before.remoteBase !== after.remoteBase
|
|
822
900
|
? "remote_base"
|
|
823
|
-
: before.
|
|
824
|
-
? "
|
|
825
|
-
:
|
|
901
|
+
: before.localBase !== after.localBase
|
|
902
|
+
? "local_base"
|
|
903
|
+
: before.targetExists !== after.targetExists
|
|
904
|
+
? "target_exists"
|
|
905
|
+
: null;
|
|
826
906
|
if (moved === null) return success(null, null, null);
|
|
827
907
|
return failure(
|
|
828
908
|
"invalid_input",
|
|
@@ -840,10 +920,13 @@ export const priorResolvedDrift = (
|
|
|
840
920
|
};
|
|
841
921
|
|
|
842
922
|
export const createAuthorizedExternalActionRunner =
|
|
843
|
-
(core: WorkitCore, bind: ExternalActionBinding): ExternalActionEffect =>
|
|
923
|
+
(core: WorkitCore, bind: ExternalActionBinding, coordinationRoot: string): ExternalActionEffect =>
|
|
844
924
|
async <T>(
|
|
845
925
|
operation: string,
|
|
846
|
-
effect: (
|
|
926
|
+
effect: (
|
|
927
|
+
step?: string,
|
|
928
|
+
reservation?: ActionReservation,
|
|
929
|
+
) => T | Result<T> | Promise<T | Result<T>>,
|
|
847
930
|
) => {
|
|
848
931
|
let steps: string[] = [];
|
|
849
932
|
try {
|
|
@@ -858,9 +941,21 @@ export const createAuthorizedExternalActionRunner =
|
|
|
858
941
|
for (const step of sequence) {
|
|
859
942
|
const binding = bind(operation);
|
|
860
943
|
if ("ok" in binding) return binding as ExternalActionResult<T>;
|
|
944
|
+
const targetRoot = externalActionTargetRoot(operation, coordinationRoot);
|
|
945
|
+
if (targetRoot === null)
|
|
946
|
+
return failure("capability_unavailable", "external action target could not be locked", {
|
|
947
|
+
outcome: "not_started",
|
|
948
|
+
});
|
|
949
|
+
const withActionLock: WithExternalActionLock | undefined = !sameDirectoryIdentity(
|
|
950
|
+
targetRoot,
|
|
951
|
+
coordinationRoot,
|
|
952
|
+
)
|
|
953
|
+
? (run) => new TaskStore(targetRoot).withExternalActionLock(run, true)
|
|
954
|
+
: undefined;
|
|
861
955
|
const result = await runAuthorizedExternalAction(
|
|
862
956
|
{ core, ...(binding as Omit<AuthorizedActionInput, "core">), ...(step ? { step } : {}) },
|
|
863
|
-
() => effect(step),
|
|
957
|
+
(reservation) => effect(step, reservation),
|
|
958
|
+
withActionLock,
|
|
864
959
|
);
|
|
865
960
|
latest = result;
|
|
866
961
|
if (!result.ok) return result;
|
|
@@ -868,6 +963,36 @@ export const createAuthorizedExternalActionRunner =
|
|
|
868
963
|
return latest ?? failure("recovery_required", "external action did not execute");
|
|
869
964
|
};
|
|
870
965
|
|
|
966
|
+
const externalActionTargetRoot = (operation: string, coordinationRoot: string): string | null => {
|
|
967
|
+
let value: unknown;
|
|
968
|
+
try {
|
|
969
|
+
value = JSON.parse(operation);
|
|
970
|
+
} catch {
|
|
971
|
+
return realpathSync(coordinationRoot);
|
|
972
|
+
}
|
|
973
|
+
if (typeof value !== "object" || value === null) return realpathSync(coordinationRoot);
|
|
974
|
+
const descriptor = value as { operation?: unknown; payload?: unknown };
|
|
975
|
+
if (
|
|
976
|
+
typeof descriptor.operation !== "string" ||
|
|
977
|
+
typeof descriptor.payload !== "object" ||
|
|
978
|
+
descriptor.payload === null
|
|
979
|
+
)
|
|
980
|
+
return realpathSync(coordinationRoot);
|
|
981
|
+
if (
|
|
982
|
+
!descriptor.operation.startsWith("git.") &&
|
|
983
|
+
!descriptor.operation.startsWith("hosting.") &&
|
|
984
|
+
descriptor.operation !== "changelog.apply"
|
|
985
|
+
)
|
|
986
|
+
return realpathSync(coordinationRoot);
|
|
987
|
+
const payload = descriptor.payload as Record<string, unknown>;
|
|
988
|
+
const cwd = typeof payload.cwd === "string" ? payload.cwd : coordinationRoot;
|
|
989
|
+
try {
|
|
990
|
+
return realpathSync(path.resolve(coordinationRoot, cwd));
|
|
991
|
+
} catch {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
871
996
|
export type NativeExternalActionObservation = {
|
|
872
997
|
actor: string;
|
|
873
998
|
actionRef: Ref;
|
|
@@ -946,7 +1071,8 @@ export const matchesNativeExternalAction = (
|
|
|
946
1071
|
*/
|
|
947
1072
|
export async function runAuthorizedExternalAction<T>(
|
|
948
1073
|
input: AuthorizedActionInput,
|
|
949
|
-
effect: () => T | Result<T> | Promise<T | Result<T>>,
|
|
1074
|
+
effect: (reservation: ActionReservation) => T | Result<T> | Promise<T | Result<T>>,
|
|
1075
|
+
withActionLock?: WithExternalActionLock,
|
|
950
1076
|
): Promise<ExternalActionResult<T>> {
|
|
951
1077
|
if (input.core.isDelegatedCaller())
|
|
952
1078
|
return failure("permission_denied", "delegated workers cannot run external actions");
|
|
@@ -981,7 +1107,7 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
981
1107
|
expectedRevision: reserved.data.taskRevision,
|
|
982
1108
|
expectedWorkspaceRevision: reserved.data.workspaceRevision,
|
|
983
1109
|
};
|
|
984
|
-
|
|
1110
|
+
const result = input.core.settleAction({
|
|
985
1111
|
...reserved.data,
|
|
986
1112
|
taskRevision: revisions.expectedRevision,
|
|
987
1113
|
workspaceRevision: revisions.expectedWorkspaceRevision,
|
|
@@ -992,6 +1118,7 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
992
1118
|
workspaceRevision: revisions.expectedWorkspaceRevision,
|
|
993
1119
|
}),
|
|
994
1120
|
});
|
|
1121
|
+
return result;
|
|
995
1122
|
} catch {
|
|
996
1123
|
return failure("external_outcome_unknown", "external action settlement is unavailable", {
|
|
997
1124
|
operation: "external_action",
|
|
@@ -1007,43 +1134,67 @@ export async function runAuthorizedExternalAction<T>(
|
|
|
1007
1134
|
? settle(outcome, true)
|
|
1008
1135
|
: first;
|
|
1009
1136
|
|
|
1010
|
-
let
|
|
1137
|
+
let effectStarted = false;
|
|
1138
|
+
const runEffect = async (): Promise<ExternalActionResult<T>> => {
|
|
1139
|
+
effectStarted = true;
|
|
1140
|
+
let effectValue: T | Result<T>;
|
|
1141
|
+
try {
|
|
1142
|
+
effectValue = await effect(reserved.data);
|
|
1143
|
+
} catch {
|
|
1144
|
+
const settled = settleWithRefresh("unknown", settle("unknown"));
|
|
1145
|
+
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1146
|
+
return failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
1147
|
+
operation: "external_action",
|
|
1148
|
+
outcome: "unknown",
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
if (
|
|
1152
|
+
typeof effectValue === "object" &&
|
|
1153
|
+
effectValue !== null &&
|
|
1154
|
+
"ok" in effectValue &&
|
|
1155
|
+
typeof (effectValue as { ok?: unknown }).ok === "boolean"
|
|
1156
|
+
) {
|
|
1157
|
+
const contract = effectValue as Result<T>;
|
|
1158
|
+
if (!contract.ok) {
|
|
1159
|
+
const preflight =
|
|
1160
|
+
typeof contract.details === "object" &&
|
|
1161
|
+
contract.details !== null &&
|
|
1162
|
+
(contract.details as Record<string, unknown>).outcome === "not_started";
|
|
1163
|
+
const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
|
|
1164
|
+
const settled = settleWithRefresh(outcome, settle(outcome));
|
|
1165
|
+
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1166
|
+
return contract;
|
|
1167
|
+
}
|
|
1168
|
+
effectValue = contract.data;
|
|
1169
|
+
}
|
|
1170
|
+
const settled = settleWithRefresh("succeeded", settle("succeeded"));
|
|
1171
|
+
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1172
|
+
return {
|
|
1173
|
+
ok: true,
|
|
1174
|
+
schemaVersion: 1,
|
|
1175
|
+
revision: settled.revision,
|
|
1176
|
+
workspaceRevision: settled.workspaceRevision,
|
|
1177
|
+
data: effectValue as T,
|
|
1178
|
+
};
|
|
1179
|
+
};
|
|
1180
|
+
let result: ExternalActionResult<T>;
|
|
1011
1181
|
try {
|
|
1012
|
-
|
|
1182
|
+
result = withActionLock ? await withActionLock(runEffect) : await runEffect();
|
|
1013
1183
|
} catch {
|
|
1014
|
-
|
|
1184
|
+
if (effectStarted)
|
|
1185
|
+
return failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
1186
|
+
operation: "external_action",
|
|
1187
|
+
outcome: "unknown",
|
|
1188
|
+
});
|
|
1189
|
+
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1015
1190
|
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1016
|
-
return failure("
|
|
1017
|
-
|
|
1018
|
-
outcome: "unknown",
|
|
1191
|
+
return failure("writer_conflict", "target checkout was unavailable before the effect", {
|
|
1192
|
+
outcome: "not_started",
|
|
1019
1193
|
});
|
|
1020
1194
|
}
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
"ok" in effectValue &&
|
|
1025
|
-
typeof (effectValue as { ok?: unknown }).ok === "boolean"
|
|
1026
|
-
) {
|
|
1027
|
-
const contract = effectValue as Result<T>;
|
|
1028
|
-
if (!contract.ok) {
|
|
1029
|
-
const preflight =
|
|
1030
|
-
typeof contract.details === "object" &&
|
|
1031
|
-
contract.details !== null &&
|
|
1032
|
-
(contract.details as Record<string, unknown>).outcome === "not_started";
|
|
1033
|
-
const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
|
|
1034
|
-
const settled = settleWithRefresh(outcome, settle(outcome));
|
|
1035
|
-
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1036
|
-
return contract;
|
|
1037
|
-
}
|
|
1038
|
-
effectValue = contract.data;
|
|
1195
|
+
if (!effectStarted) {
|
|
1196
|
+
const settled = settleWithRefresh("not_started", settle("not_started"));
|
|
1197
|
+
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1039
1198
|
}
|
|
1040
|
-
|
|
1041
|
-
if (!settled.ok) return settled as ExternalActionResult<T>;
|
|
1042
|
-
return {
|
|
1043
|
-
ok: true,
|
|
1044
|
-
schemaVersion: 1,
|
|
1045
|
-
revision: settled.revision,
|
|
1046
|
-
workspaceRevision: settled.workspaceRevision,
|
|
1047
|
-
data: effectValue as T,
|
|
1048
|
-
};
|
|
1199
|
+
return result;
|
|
1049
1200
|
}
|