@brainervirus/workit-core 1.2.0 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +31 -8
  17. package/src/core/auto-approval.ts +44 -32
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +1102 -209
  25. package/src/core/external-action.ts +230 -103
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +56 -52
  29. package/src/core/ports/pr-create.ts +23 -15
  30. package/src/core/pr-create.ts +374 -84
  31. package/src/core/registration.ts +0 -15
  32. package/src/core/repo-tools.ts +0 -1
  33. package/src/core/route-intent.ts +34 -82
  34. package/src/core/safe-write.ts +66 -1
  35. package/src/core/setup-state.ts +7 -0
  36. package/src/core/setup.ts +175 -82
  37. package/src/core/task-contract.ts +16 -3
  38. package/src/core/task-engine.ts +136 -31
  39. package/src/core/task-evaluation.ts +3 -5
  40. package/src/core/task-store.ts +159 -1
  41. package/src/core/tracker-issues.ts +55 -118
  42. package/src/core/vcs-config.ts +178 -187
  43. package/src/core/workers.ts +17 -0
  44. package/src/core/workspaces.ts +548 -46
  45. package/src/core.ts +1 -2
  46. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -13,10 +13,23 @@ import {
13
13
  sha256,
14
14
  } from "./task-contract";
15
15
  import { WorkitCore } from "./task-engine";
16
- import { TaskStore } from "./task-store";
17
- import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
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";
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
+ ]);
20
33
 
21
34
  export type AuthorizedActionInput = {
22
35
  core: WorkitCore;
@@ -39,6 +52,9 @@ export type AuthorizedActionInput = {
39
52
  };
40
53
 
41
54
  export type ExternalActionResult<T> = Result<T>;
55
+ type WithExternalActionLock = <T>(
56
+ operation: () => Promise<ExternalActionResult<T>>,
57
+ ) => Promise<ExternalActionResult<T>>;
42
58
 
43
59
  const externalActionSchema = z.discriminatedUnion("operation", [
44
60
  z
@@ -50,6 +66,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
50
66
  sdd_dir: z.string().optional(),
51
67
  target_branch: z.string().optional(),
52
68
  stash: z.enum(["yes", "no"]).optional(),
69
+ cwd: z.string().min(1).optional(),
53
70
  })
54
71
  .strict(),
55
72
  })
@@ -60,8 +77,9 @@ const externalActionSchema = z.discriminatedUnion("operation", [
60
77
  payload: z
61
78
  .object({
62
79
  message: z.string().min(1).optional(),
63
- plan_steps: z.array(z.string().min(1)).optional(),
80
+ plan_steps: z.array(chainStepSchema).min(1).max(32).optional(),
64
81
  plan_branch: z.string().min(1).optional(),
82
+ cwd: z.string().min(1).optional(),
65
83
  })
66
84
  .strict(),
67
85
  })
@@ -69,7 +87,9 @@ const externalActionSchema = z.discriminatedUnion("operation", [
69
87
  z
70
88
  .object({
71
89
  operation: z.literal("git.push"),
72
- payload: z.object({ branch: z.string().min(1).optional() }).strict(),
90
+ payload: z
91
+ .object({ branch: z.string().min(1).optional(), cwd: z.string().min(1).optional() })
92
+ .strict(),
73
93
  })
74
94
  .strict(),
75
95
  z
@@ -82,6 +102,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
82
102
  draft: z.boolean().optional(),
83
103
  target_branch: z.string().optional(),
84
104
  babysit: z.boolean().optional(),
105
+ cwd: z.string().min(1).optional(),
85
106
  })
86
107
  .strict(),
87
108
  })
@@ -93,10 +114,17 @@ const externalActionSchema = z.discriminatedUnion("operation", [
93
114
  .object({
94
115
  target_branch: z.string().min(1).optional(),
95
116
  source_branch: z.string().min(1).optional(),
117
+ cwd: z.string().min(1).optional(),
96
118
  })
97
119
  .strict(),
98
120
  })
99
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(),
100
128
  z
101
129
  .object({
102
130
  operation: z.literal("youtrack.update"),
@@ -173,6 +201,7 @@ const externalActionSchema = z.discriminatedUnion("operation", [
173
201
  mode: z.string().optional(),
174
202
  specPath: z.string().optional(),
175
203
  planPath: z.string().optional(),
204
+ cwd: z.string().min(1).optional(),
176
205
  })
177
206
  .strict(),
178
207
  })
@@ -199,7 +228,10 @@ export type ExternalActionRunner = <T>(
199
228
  ) => Promise<ExternalActionResult<T>>;
200
229
  export type ExternalActionEffect = <T>(
201
230
  operation: string,
202
- effect: (step?: string) => T | Result<T> | Promise<T | Result<T>>,
231
+ effect: (
232
+ step?: string,
233
+ reservation?: ActionReservation,
234
+ ) => T | Result<T> | Promise<T | Result<T>>,
203
235
  ) => Promise<ExternalActionResult<T>>;
204
236
  export type ExternalActionBinding = (
205
237
  operation: string,
@@ -244,7 +276,7 @@ export const planReservationLength = (
244
276
 
245
277
  /** Compact host-facing help for the fixed optional-action surface. */
246
278
  export const externalActionHelp =
247
- "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).";
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).";
248
280
 
249
281
  export const externalActionRef = (
250
282
  host: "opencode" | "pi" | "workit_cli",
@@ -268,14 +300,7 @@ export const externalActionState = (
268
300
  return failure("storage_error", "external action state is unavailable");
269
301
  if (!workspace.data) return failure("not_found", "workspace not found");
270
302
  const candidates = listed.data.flatMap((task) => {
271
- if (
272
- task.status !== "active" ||
273
- task.workspaceId !== workspace.data!.id ||
274
- task.intent.provenance.session?.kind !== "host" ||
275
- task.intent.provenance.session.host !== host ||
276
- task.intent.provenance.session.handle !== actor
277
- )
278
- return [];
303
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
279
304
  return task.decisions
280
305
  .filter(
281
306
  (entry) =>
@@ -290,8 +315,8 @@ export const externalActionState = (
290
315
  return failure(
291
316
  "permission_denied",
292
317
  candidates.length === 0
293
- ? "no approved action is bound to this session"
294
- : "multiple approved actions match this session",
318
+ ? "no approved action is bound to the active task"
319
+ : "multiple approved actions match the active task",
295
320
  );
296
321
  return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
297
322
  };
@@ -352,13 +377,14 @@ export const priorExternalAction = (
352
377
  /** Parse a plan-commit authorization descriptor (one approved list per plan). */
353
378
  export const planCommitDescriptor = (
354
379
  content: string,
355
- ): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
380
+ ): { steps: ChainStep[]; branch: string; snapshotHead: string | null; cwd?: string } | null => {
356
381
  try {
357
382
  const value = JSON.parse(content) as {
358
383
  operation?: unknown;
359
384
  payload?: {
360
385
  plan_steps?: unknown;
361
386
  plan_branch?: unknown;
387
+ cwd?: unknown;
362
388
  resolved?: { head?: unknown; branch?: unknown };
363
389
  };
364
390
  };
@@ -366,8 +392,15 @@ export const planCommitDescriptor = (
366
392
  const steps = normalizeChainSteps(value.payload.plan_steps);
367
393
  const branch = value.payload.plan_branch;
368
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;
369
397
  const head = value.payload.resolved?.head;
370
- return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
398
+ return {
399
+ steps,
400
+ branch,
401
+ snapshotHead: typeof head === "string" ? head : null,
402
+ ...(typeof cwd === "string" ? { cwd } : {}),
403
+ };
371
404
  } catch {
372
405
  return null;
373
406
  }
@@ -378,17 +411,21 @@ export const planCommitDescriptor = (
378
411
  * HEAD — history only moved forward. Rebase/reset underneath an approved
379
412
  * chain invalidates it instead of executing on rewritten history.
380
413
  */
381
- export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
414
+ export const chainLeaseValid = (
415
+ store: TaskStore,
416
+ snapshotHead: string | null,
417
+ targetRoot = store.root,
418
+ ): boolean => {
382
419
  if (!snapshotHead) return true;
383
420
  try {
384
421
  const head = execFileSync("git", ["rev-parse", "HEAD"], {
385
- cwd: store.root,
422
+ cwd: targetRoot,
386
423
  encoding: "utf8",
387
424
  stdio: ["pipe", "pipe", "pipe"],
388
425
  }).trim();
389
426
  if (head === snapshotHead) return true;
390
427
  execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
391
- cwd: store.root,
428
+ cwd: targetRoot,
392
429
  encoding: "utf8",
393
430
  stdio: ["pipe", "pipe", "pipe"],
394
431
  });
@@ -421,6 +458,7 @@ export const approvedPlanCommit = (
421
458
  host: string,
422
459
  actor: string,
423
460
  message: string,
461
+ targetRoot = store.root,
424
462
  ): Result<PlanCommitAuthorization> => {
425
463
  if (typeof message !== "string" || !message)
426
464
  return failure("invalid_input", "commit message is required");
@@ -431,12 +469,6 @@ export const approvedPlanCommit = (
431
469
  if (!workspace.data) return failure("not_found", "workspace not found");
432
470
  const matches = listed.data.flatMap((task) => {
433
471
  if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
434
- if (
435
- task.intent.provenance.session?.kind !== "host" ||
436
- task.intent.provenance.session.host !== host ||
437
- task.intent.provenance.session.handle !== actor
438
- )
439
- return [];
440
472
  return task.decisions.flatMap((entry) => {
441
473
  const decision = entry.data;
442
474
  if (
@@ -449,12 +481,13 @@ export const approvedPlanCommit = (
449
481
  return [];
450
482
  const plan = planCommitDescriptor(decision.binding.approvedContent);
451
483
  if (!plan) return [];
484
+ if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
452
485
  const completed =
453
486
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
454
487
  [];
455
488
  const next = plan.steps[completed.length];
456
489
  if (!next || next.kind !== "commit" || next.message !== message) return [];
457
- if (!chainLeaseValid(store, plan.snapshotHead)) return [];
490
+ if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
458
491
  return [
459
492
  {
460
493
  task,
@@ -478,15 +511,10 @@ export const approvedPlanCommit = (
478
511
  };
479
512
 
480
513
  export type ChainStepQuery =
481
- | { operation: "git.branch_setup"; target: string }
482
- | { operation: "hosting.pull_request" };
514
+ | { operation: "git.branch_setup"; target: string; cwd?: string }
515
+ | { operation: "hosting.pull_request"; cwd?: string };
483
516
 
484
- /**
485
- * Match one branch or PR step of a chain authorization: same session-bound
486
- * search as plan commits, next unconsumed step of the right kind, lease
487
- * intact. Commit steps stay on the plan path; this covers the steps plans
488
- * could never express.
489
- */
517
+ /** Match the next branch or PR step of an active chain authorization. */
490
518
  export const approvedChainStep = (
491
519
  store: TaskStore,
492
520
  host: string,
@@ -500,12 +528,6 @@ export const approvedChainStep = (
500
528
  if (!workspace.data) return failure("not_found", "workspace not found");
501
529
  const matches = listed.data.flatMap((task) => {
502
530
  if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
503
- if (
504
- task.intent.provenance.session?.kind !== "host" ||
505
- task.intent.provenance.session.host !== host ||
506
- task.intent.provenance.session.handle !== actor
507
- )
508
- return [];
509
531
  return task.decisions.flatMap((entry) => {
510
532
  const decision = entry.data;
511
533
  if (
@@ -518,6 +540,8 @@ export const approvedChainStep = (
518
540
  return [];
519
541
  const plan = planCommitDescriptor(decision.binding.approvedContent);
520
542
  if (!plan) return [];
543
+ const targetRoot = query.cwd ?? store.root;
544
+ if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
521
545
  const completed =
522
546
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
523
547
  [];
@@ -528,7 +552,7 @@ export const approvedChainStep = (
528
552
  } else if (next.kind !== "pr") {
529
553
  return [];
530
554
  }
531
- if (!chainLeaseValid(store, plan.snapshotHead)) return [];
555
+ if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
532
556
  return [
533
557
  {
534
558
  task,
@@ -565,6 +589,17 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
565
589
  }
566
590
  };
567
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
+
568
603
  /** Resolve a chain authorization into the exact binding an adapter needs
569
604
  * to run one branch or PR step under the approved chain. Commit steps stay
570
605
  * on the plan path; this covers the steps plans could never express. */
@@ -590,12 +625,18 @@ export const chainStepBinding = (
590
625
  } catch {
591
626
  return null;
592
627
  }
628
+ const targetRoot = descriptorTargetRoot(store, operation);
629
+ if (!targetRoot) return null;
593
630
  const query: ChainStepQuery | null =
594
631
  descriptor.operation === "git.branch_setup" &&
595
632
  typeof descriptor.payload?.target_branch === "string"
596
- ? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
633
+ ? {
634
+ operation: "git.branch_setup",
635
+ target: descriptor.payload.target_branch as string,
636
+ cwd: targetRoot,
637
+ }
597
638
  : descriptor.operation === "hosting.pull_request"
598
- ? { operation: "hosting.pull_request" }
639
+ ? { operation: "hosting.pull_request", cwd: targetRoot }
599
640
  : null;
600
641
  if (!query) return null;
601
642
  const chain = approvedChainStep(store, host, actor, query);
@@ -605,7 +646,7 @@ export const chainStepBinding = (
605
646
  let branch: string;
606
647
  try {
607
648
  branch = execFileSync("git", ["branch", "--show-current"], {
608
- cwd: store.root,
649
+ cwd: targetRoot,
609
650
  encoding: "utf8",
610
651
  stdio: ["pipe", "pipe", "pipe"],
611
652
  }).trim();
@@ -645,12 +686,14 @@ export const planCommitBinding = (
645
686
  } | null => {
646
687
  const message = commitMessageFromDescriptor(operation);
647
688
  if (!message) return null;
648
- const plan = approvedPlanCommit(store, host, actor, message);
689
+ const targetRoot = descriptorTargetRoot(store, operation);
690
+ if (!targetRoot) return null;
691
+ const plan = approvedPlanCommit(store, host, actor, message, targetRoot);
649
692
  if (!plan.ok) return null;
650
693
  let branch: string;
651
694
  try {
652
695
  branch = execFileSync("git", ["branch", "--show-current"], {
653
- cwd: store.root,
696
+ cwd: targetRoot,
654
697
  encoding: "utf8",
655
698
  stdio: ["pipe", "pipe", "pipe"],
656
699
  }).trim();
@@ -709,18 +752,26 @@ export const approvedExternalAction = (
709
752
  return selected;
710
753
  };
711
754
 
712
- /** Intent identity for a branch-setup descriptor: target and base bind the
713
- * 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. */
714
756
  export const branchSetupIntent = (
715
757
  descriptor: string,
716
- ): { target: string; base: string; remoteBase: string | null; targetExists: boolean } | null => {
758
+ ): {
759
+ target: string;
760
+ base: string;
761
+ localBase: string | null;
762
+ remoteBase: string | null;
763
+ targetExists: boolean;
764
+ cwd?: string;
765
+ } | null => {
717
766
  try {
718
767
  const value = JSON.parse(descriptor) as {
719
768
  operation?: unknown;
720
769
  payload?: {
721
770
  target_branch?: unknown;
771
+ cwd?: unknown;
722
772
  resolved?: {
723
773
  base_branch?: unknown;
774
+ local_base?: unknown;
724
775
  remote_base?: unknown;
725
776
  target_exists?: unknown;
726
777
  };
@@ -729,13 +780,20 @@ export const branchSetupIntent = (
729
780
  if (value.operation !== "git.branch_setup" || !value.payload) return null;
730
781
  const { target_branch, resolved } = value.payload;
731
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;
732
788
  if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
733
789
  return null;
734
790
  return {
735
791
  target: target_branch,
736
792
  base: resolved.base_branch,
793
+ localBase: typeof resolved.local_base === "string" ? resolved.local_base : null,
737
794
  remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
738
795
  targetExists: resolved.target_exists,
796
+ ...(typeof value.payload.cwd === "string" ? { cwd: value.payload.cwd } : {}),
739
797
  };
740
798
  } catch {
741
799
  return null;
@@ -751,21 +809,15 @@ export const approvedBranchSetupIntent = (
751
809
  operation: string,
752
810
  ) => {
753
811
  const current = branchSetupIntent(operation);
754
- if (!current) return failure("permission_denied", "no approved action is bound to this session");
812
+ if (!current)
813
+ return failure("permission_denied", "no approved action is bound to the active task");
755
814
  const listed = store.listTasks();
756
815
  const workspace = store.readWorkspace();
757
816
  if (!listed.ok || !workspace.ok)
758
817
  return failure("storage_error", "external action state is unavailable");
759
818
  if (!workspace.data) return failure("not_found", "workspace not found");
760
819
  const candidates = listed.data.flatMap((task) => {
761
- if (
762
- task.status !== "active" ||
763
- task.workspaceId !== workspace.data!.id ||
764
- task.intent.provenance.session?.kind !== "host" ||
765
- task.intent.provenance.session.host !== host ||
766
- task.intent.provenance.session.handle !== actor
767
- )
768
- return [];
820
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
769
821
  return task.decisions
770
822
  .filter(
771
823
  (entry) =>
@@ -779,8 +831,10 @@ export const approvedBranchSetupIntent = (
779
831
  const intent = branchSetupIntent(entry.data.binding.approvedContent);
780
832
  return (
781
833
  intent !== null &&
834
+ (intent.cwd ?? store.root) === (current.cwd ?? store.root) &&
782
835
  intent.target === current.target &&
783
836
  intent.base === current.base &&
837
+ intent.localBase === current.localBase &&
784
838
  intent.remoteBase === current.remoteBase &&
785
839
  intent.targetExists === current.targetExists
786
840
  );
@@ -789,8 +843,8 @@ export const approvedBranchSetupIntent = (
789
843
  return failure(
790
844
  "permission_denied",
791
845
  matches.length === 0
792
- ? "no approved action is bound to this session"
793
- : "multiple approved actions match this session",
846
+ ? "no approved action is bound to the active task"
847
+ : "multiple approved actions match the active task",
794
848
  );
795
849
  return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
796
850
  };
@@ -844,9 +898,11 @@ export const priorResolvedDrift = (
844
898
  ? "base_branch"
845
899
  : before.remoteBase !== after.remoteBase
846
900
  ? "remote_base"
847
- : before.targetExists !== after.targetExists
848
- ? "target_exists"
849
- : null;
901
+ : before.localBase !== after.localBase
902
+ ? "local_base"
903
+ : before.targetExists !== after.targetExists
904
+ ? "target_exists"
905
+ : null;
850
906
  if (moved === null) return success(null, null, null);
851
907
  return failure(
852
908
  "invalid_input",
@@ -864,10 +920,13 @@ export const priorResolvedDrift = (
864
920
  };
865
921
 
866
922
  export const createAuthorizedExternalActionRunner =
867
- (core: WorkitCore, bind: ExternalActionBinding): ExternalActionEffect =>
923
+ (core: WorkitCore, bind: ExternalActionBinding, coordinationRoot: string): ExternalActionEffect =>
868
924
  async <T>(
869
925
  operation: string,
870
- effect: (step?: string) => T | Result<T> | Promise<T | Result<T>>,
926
+ effect: (
927
+ step?: string,
928
+ reservation?: ActionReservation,
929
+ ) => T | Result<T> | Promise<T | Result<T>>,
871
930
  ) => {
872
931
  let steps: string[] = [];
873
932
  try {
@@ -882,9 +941,21 @@ export const createAuthorizedExternalActionRunner =
882
941
  for (const step of sequence) {
883
942
  const binding = bind(operation);
884
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;
885
955
  const result = await runAuthorizedExternalAction(
886
956
  { core, ...(binding as Omit<AuthorizedActionInput, "core">), ...(step ? { step } : {}) },
887
- () => effect(step),
957
+ (reservation) => effect(step, reservation),
958
+ withActionLock,
888
959
  );
889
960
  latest = result;
890
961
  if (!result.ok) return result;
@@ -892,6 +963,36 @@ export const createAuthorizedExternalActionRunner =
892
963
  return latest ?? failure("recovery_required", "external action did not execute");
893
964
  };
894
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
+
895
996
  export type NativeExternalActionObservation = {
896
997
  actor: string;
897
998
  actionRef: Ref;
@@ -970,7 +1071,8 @@ export const matchesNativeExternalAction = (
970
1071
  */
971
1072
  export async function runAuthorizedExternalAction<T>(
972
1073
  input: AuthorizedActionInput,
973
- effect: () => T | Result<T> | Promise<T | Result<T>>,
1074
+ effect: (reservation: ActionReservation) => T | Result<T> | Promise<T | Result<T>>,
1075
+ withActionLock?: WithExternalActionLock,
974
1076
  ): Promise<ExternalActionResult<T>> {
975
1077
  if (input.core.isDelegatedCaller())
976
1078
  return failure("permission_denied", "delegated workers cannot run external actions");
@@ -1005,7 +1107,7 @@ export async function runAuthorizedExternalAction<T>(
1005
1107
  expectedRevision: reserved.data.taskRevision,
1006
1108
  expectedWorkspaceRevision: reserved.data.workspaceRevision,
1007
1109
  };
1008
- return input.core.settleAction({
1110
+ const result = input.core.settleAction({
1009
1111
  ...reserved.data,
1010
1112
  taskRevision: revisions.expectedRevision,
1011
1113
  workspaceRevision: revisions.expectedWorkspaceRevision,
@@ -1016,6 +1118,7 @@ export async function runAuthorizedExternalAction<T>(
1016
1118
  workspaceRevision: revisions.expectedWorkspaceRevision,
1017
1119
  }),
1018
1120
  });
1121
+ return result;
1019
1122
  } catch {
1020
1123
  return failure("external_outcome_unknown", "external action settlement is unavailable", {
1021
1124
  operation: "external_action",
@@ -1031,43 +1134,67 @@ export async function runAuthorizedExternalAction<T>(
1031
1134
  ? settle(outcome, true)
1032
1135
  : first;
1033
1136
 
1034
- let effectValue: T | Result<T>;
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>;
1035
1181
  try {
1036
- effectValue = await effect();
1182
+ result = withActionLock ? await withActionLock(runEffect) : await runEffect();
1037
1183
  } catch {
1038
- const settled = settleWithRefresh("unknown", settle("unknown"));
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"));
1039
1190
  if (!settled.ok) return settled as ExternalActionResult<T>;
1040
- return failure("external_outcome_unknown", "external action outcome is unknown", {
1041
- operation: "external_action",
1042
- outcome: "unknown",
1191
+ return failure("writer_conflict", "target checkout was unavailable before the effect", {
1192
+ outcome: "not_started",
1043
1193
  });
1044
1194
  }
1045
- if (
1046
- typeof effectValue === "object" &&
1047
- effectValue !== null &&
1048
- "ok" in effectValue &&
1049
- typeof (effectValue as { ok?: unknown }).ok === "boolean"
1050
- ) {
1051
- const contract = effectValue as Result<T>;
1052
- if (!contract.ok) {
1053
- const preflight =
1054
- typeof contract.details === "object" &&
1055
- contract.details !== null &&
1056
- (contract.details as Record<string, unknown>).outcome === "not_started";
1057
- const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
1058
- const settled = settleWithRefresh(outcome, settle(outcome));
1059
- if (!settled.ok) return settled as ExternalActionResult<T>;
1060
- return contract;
1061
- }
1062
- effectValue = contract.data;
1195
+ if (!effectStarted) {
1196
+ const settled = settleWithRefresh("not_started", settle("not_started"));
1197
+ if (!settled.ok) return settled as ExternalActionResult<T>;
1063
1198
  }
1064
- const settled = settleWithRefresh("succeeded", settle("succeeded"));
1065
- if (!settled.ok) return settled as ExternalActionResult<T>;
1066
- return {
1067
- ok: true,
1068
- schemaVersion: 1,
1069
- revision: settled.revision,
1070
- workspaceRevision: settled.workspaceRevision,
1071
- data: effectValue as T,
1072
- };
1199
+ return result;
1073
1200
  }