@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.
Files changed (44) 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 +6 -1
  17. package/src/core/auto-approval.ts +34 -14
  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 +886 -172
  25. package/src/core/external-action.ts +214 -63
  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 +7 -1
  38. package/src/core/task-engine.ts +47 -2
  39. package/src/core/task-store.ts +159 -1
  40. package/src/core/tracker-issues.ts +55 -118
  41. package/src/core/vcs-config.ts +178 -187
  42. package/src/core/workspaces.ts +548 -46
  43. package/src/core.ts +1 -2
  44. 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 { 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";
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.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(),
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: (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>>,
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: 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).";
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 { 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
+ };
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 = (store: TaskStore, snapshotHead: string | null): boolean => {
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: store.root,
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: store.root,
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
- ? { 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
+ }
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: store.root,
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 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);
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: store.root,
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 a branch-setup descriptor: target and base bind the
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
- ): { 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 => {
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.targetExists !== after.targetExists
824
- ? "target_exists"
825
- : null;
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: (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>>,
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
- return input.core.settleAction({
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 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>;
1011
1181
  try {
1012
- effectValue = await effect();
1182
+ result = withActionLock ? await withActionLock(runEffect) : await runEffect();
1013
1183
  } catch {
1014
- 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"));
1015
1190
  if (!settled.ok) return settled as ExternalActionResult<T>;
1016
- return failure("external_outcome_unknown", "external action outcome is unknown", {
1017
- operation: "external_action",
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
- typeof effectValue === "object" &&
1023
- effectValue !== null &&
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
- const settled = settleWithRefresh("succeeded", settle("succeeded"));
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
  }