@brainervirus/workit-core 1.2.1 → 1.3.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.
Files changed (45) 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 +222 -64
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +65 -52
  29. package/src/core/policy-resolver.ts +13 -5
  30. package/src/core/ports/pr-create.ts +23 -15
  31. package/src/core/pr-create.ts +374 -84
  32. package/src/core/registration.ts +0 -15
  33. package/src/core/repo-tools.ts +0 -1
  34. package/src/core/route-intent.ts +34 -82
  35. package/src/core/safe-write.ts +66 -1
  36. package/src/core/setup-state.ts +7 -0
  37. package/src/core/setup.ts +175 -82
  38. package/src/core/task-contract.ts +66 -6
  39. package/src/core/task-engine.ts +47 -2
  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/workspaces.ts +554 -46
  44. package/src/core.ts +3 -2
  45. 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,8 +52,11 @@ 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
- const externalActionSchema = z.discriminatedUnion("operation", [
59
+ export const externalActionSchema = z.discriminatedUnion("operation", [
50
60
  z
51
61
  .object({
52
62
  operation: z.literal("git.branch_setup"),
@@ -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
  })
@@ -186,6 +209,13 @@ const externalActionSchema = z.discriminatedUnion("operation", [
186
209
  ]);
187
210
  export type ExternalActionRequest = z.infer<typeof externalActionSchema>;
188
211
 
212
+ /** The complete external-action contract for hosts that register native tools. */
213
+ export const externalActionJsonSchema = (): z.core.JSONSchema.BaseSchema =>
214
+ ({
215
+ type: "object",
216
+ ...z.toJSONSchema(externalActionSchema, { target: "draft-2020-12" }),
217
+ }) as z.core.JSONSchema.BaseSchema;
218
+
189
219
  export type ExternalActionOperation = ExternalActionRequest["operation"];
190
220
 
191
221
  export const externalActionRequest = (value: unknown): Result<ExternalActionRequest> => {
@@ -205,7 +235,10 @@ export type ExternalActionRunner = <T>(
205
235
  ) => Promise<ExternalActionResult<T>>;
206
236
  export type ExternalActionEffect = <T>(
207
237
  operation: string,
208
- effect: (step?: string) => T | Result<T> | Promise<T | Result<T>>,
238
+ effect: (
239
+ step?: string,
240
+ reservation?: ActionReservation,
241
+ ) => T | Result<T> | Promise<T | Result<T>>,
209
242
  ) => Promise<ExternalActionResult<T>>;
210
243
  export type ExternalActionBinding = (
211
244
  operation: string,
@@ -250,7 +283,7 @@ export const planReservationLength = (
250
283
 
251
284
  /** Compact host-facing help for the fixed optional-action surface. */
252
285
  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).";
286
+ "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
287
 
255
288
  export const externalActionRef = (
256
289
  host: "opencode" | "pi" | "workit_cli",
@@ -351,13 +384,14 @@ export const priorExternalAction = (
351
384
  /** Parse a plan-commit authorization descriptor (one approved list per plan). */
352
385
  export const planCommitDescriptor = (
353
386
  content: string,
354
- ): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
387
+ ): { steps: ChainStep[]; branch: string; snapshotHead: string | null; cwd?: string } | null => {
355
388
  try {
356
389
  const value = JSON.parse(content) as {
357
390
  operation?: unknown;
358
391
  payload?: {
359
392
  plan_steps?: unknown;
360
393
  plan_branch?: unknown;
394
+ cwd?: unknown;
361
395
  resolved?: { head?: unknown; branch?: unknown };
362
396
  };
363
397
  };
@@ -365,8 +399,15 @@ export const planCommitDescriptor = (
365
399
  const steps = normalizeChainSteps(value.payload.plan_steps);
366
400
  const branch = value.payload.plan_branch;
367
401
  if (!steps || typeof branch !== "string" || !branch) return null;
402
+ const cwd = value.payload.cwd;
403
+ if (cwd !== undefined && (typeof cwd !== "string" || !path.isAbsolute(cwd))) return null;
368
404
  const head = value.payload.resolved?.head;
369
- return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
405
+ return {
406
+ steps,
407
+ branch,
408
+ snapshotHead: typeof head === "string" ? head : null,
409
+ ...(typeof cwd === "string" ? { cwd } : {}),
410
+ };
370
411
  } catch {
371
412
  return null;
372
413
  }
@@ -377,17 +418,21 @@ export const planCommitDescriptor = (
377
418
  * HEAD — history only moved forward. Rebase/reset underneath an approved
378
419
  * chain invalidates it instead of executing on rewritten history.
379
420
  */
380
- export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
421
+ export const chainLeaseValid = (
422
+ store: TaskStore,
423
+ snapshotHead: string | null,
424
+ targetRoot = store.root,
425
+ ): boolean => {
381
426
  if (!snapshotHead) return true;
382
427
  try {
383
428
  const head = execFileSync("git", ["rev-parse", "HEAD"], {
384
- cwd: store.root,
429
+ cwd: targetRoot,
385
430
  encoding: "utf8",
386
431
  stdio: ["pipe", "pipe", "pipe"],
387
432
  }).trim();
388
433
  if (head === snapshotHead) return true;
389
434
  execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
390
- cwd: store.root,
435
+ cwd: targetRoot,
391
436
  encoding: "utf8",
392
437
  stdio: ["pipe", "pipe", "pipe"],
393
438
  });
@@ -420,6 +465,7 @@ export const approvedPlanCommit = (
420
465
  host: string,
421
466
  actor: string,
422
467
  message: string,
468
+ targetRoot = store.root,
423
469
  ): Result<PlanCommitAuthorization> => {
424
470
  if (typeof message !== "string" || !message)
425
471
  return failure("invalid_input", "commit message is required");
@@ -442,12 +488,13 @@ export const approvedPlanCommit = (
442
488
  return [];
443
489
  const plan = planCommitDescriptor(decision.binding.approvedContent);
444
490
  if (!plan) return [];
491
+ if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
445
492
  const completed =
446
493
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
447
494
  [];
448
495
  const next = plan.steps[completed.length];
449
496
  if (!next || next.kind !== "commit" || next.message !== message) return [];
450
- if (!chainLeaseValid(store, plan.snapshotHead)) return [];
497
+ if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
451
498
  return [
452
499
  {
453
500
  task,
@@ -471,8 +518,8 @@ export const approvedPlanCommit = (
471
518
  };
472
519
 
473
520
  export type ChainStepQuery =
474
- | { operation: "git.branch_setup"; target: string }
475
- | { operation: "hosting.pull_request" };
521
+ | { operation: "git.branch_setup"; target: string; cwd?: string }
522
+ | { operation: "hosting.pull_request"; cwd?: string };
476
523
 
477
524
  /** Match the next branch or PR step of an active chain authorization. */
478
525
  export const approvedChainStep = (
@@ -500,6 +547,8 @@ export const approvedChainStep = (
500
547
  return [];
501
548
  const plan = planCommitDescriptor(decision.binding.approvedContent);
502
549
  if (!plan) return [];
550
+ const targetRoot = query.cwd ?? store.root;
551
+ if (!sameDirectoryIdentity(plan.cwd ?? store.root, targetRoot)) return [];
503
552
  const completed =
504
553
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
505
554
  [];
@@ -510,7 +559,7 @@ export const approvedChainStep = (
510
559
  } else if (next.kind !== "pr") {
511
560
  return [];
512
561
  }
513
- if (!chainLeaseValid(store, plan.snapshotHead)) return [];
562
+ if (!chainLeaseValid(store, plan.snapshotHead, targetRoot)) return [];
514
563
  return [
515
564
  {
516
565
  task,
@@ -547,6 +596,17 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
547
596
  }
548
597
  };
549
598
 
599
+ const descriptorTargetRoot = (store: TaskStore, operation: string): string | null => {
600
+ try {
601
+ const descriptor = JSON.parse(operation) as { payload?: { cwd?: unknown } };
602
+ const cwd = descriptor.payload?.cwd;
603
+ if (cwd === undefined) return store.root;
604
+ return typeof cwd === "string" && path.isAbsolute(cwd) ? realpathSync(cwd) : null;
605
+ } catch {
606
+ return null;
607
+ }
608
+ };
609
+
550
610
  /** Resolve a chain authorization into the exact binding an adapter needs
551
611
  * to run one branch or PR step under the approved chain. Commit steps stay
552
612
  * on the plan path; this covers the steps plans could never express. */
@@ -572,12 +632,18 @@ export const chainStepBinding = (
572
632
  } catch {
573
633
  return null;
574
634
  }
635
+ const targetRoot = descriptorTargetRoot(store, operation);
636
+ if (!targetRoot) return null;
575
637
  const query: ChainStepQuery | null =
576
638
  descriptor.operation === "git.branch_setup" &&
577
639
  typeof descriptor.payload?.target_branch === "string"
578
- ? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
640
+ ? {
641
+ operation: "git.branch_setup",
642
+ target: descriptor.payload.target_branch as string,
643
+ cwd: targetRoot,
644
+ }
579
645
  : descriptor.operation === "hosting.pull_request"
580
- ? { operation: "hosting.pull_request" }
646
+ ? { operation: "hosting.pull_request", cwd: targetRoot }
581
647
  : null;
582
648
  if (!query) return null;
583
649
  const chain = approvedChainStep(store, host, actor, query);
@@ -587,7 +653,7 @@ export const chainStepBinding = (
587
653
  let branch: string;
588
654
  try {
589
655
  branch = execFileSync("git", ["branch", "--show-current"], {
590
- cwd: store.root,
656
+ cwd: targetRoot,
591
657
  encoding: "utf8",
592
658
  stdio: ["pipe", "pipe", "pipe"],
593
659
  }).trim();
@@ -627,12 +693,14 @@ export const planCommitBinding = (
627
693
  } | null => {
628
694
  const message = commitMessageFromDescriptor(operation);
629
695
  if (!message) return null;
630
- const plan = approvedPlanCommit(store, host, actor, message);
696
+ const targetRoot = descriptorTargetRoot(store, operation);
697
+ if (!targetRoot) return null;
698
+ const plan = approvedPlanCommit(store, host, actor, message, targetRoot);
631
699
  if (!plan.ok) return null;
632
700
  let branch: string;
633
701
  try {
634
702
  branch = execFileSync("git", ["branch", "--show-current"], {
635
- cwd: store.root,
703
+ cwd: targetRoot,
636
704
  encoding: "utf8",
637
705
  stdio: ["pipe", "pipe", "pipe"],
638
706
  }).trim();
@@ -691,18 +759,26 @@ export const approvedExternalAction = (
691
759
  return selected;
692
760
  };
693
761
 
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. */
762
+ /** Intent identity for branch setup includes the exact remote and local base commits. */
696
763
  export const branchSetupIntent = (
697
764
  descriptor: string,
698
- ): { target: string; base: string; remoteBase: string | null; targetExists: boolean } | null => {
765
+ ): {
766
+ target: string;
767
+ base: string;
768
+ localBase: string | null;
769
+ remoteBase: string | null;
770
+ targetExists: boolean;
771
+ cwd?: string;
772
+ } | null => {
699
773
  try {
700
774
  const value = JSON.parse(descriptor) as {
701
775
  operation?: unknown;
702
776
  payload?: {
703
777
  target_branch?: unknown;
778
+ cwd?: unknown;
704
779
  resolved?: {
705
780
  base_branch?: unknown;
781
+ local_base?: unknown;
706
782
  remote_base?: unknown;
707
783
  target_exists?: unknown;
708
784
  };
@@ -711,13 +787,20 @@ export const branchSetupIntent = (
711
787
  if (value.operation !== "git.branch_setup" || !value.payload) return null;
712
788
  const { target_branch, resolved } = value.payload;
713
789
  if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
790
+ if (
791
+ value.payload.cwd !== undefined &&
792
+ (typeof value.payload.cwd !== "string" || !path.isAbsolute(value.payload.cwd))
793
+ )
794
+ return null;
714
795
  if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
715
796
  return null;
716
797
  return {
717
798
  target: target_branch,
718
799
  base: resolved.base_branch,
800
+ localBase: typeof resolved.local_base === "string" ? resolved.local_base : null,
719
801
  remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
720
802
  targetExists: resolved.target_exists,
803
+ ...(typeof value.payload.cwd === "string" ? { cwd: value.payload.cwd } : {}),
721
804
  };
722
805
  } catch {
723
806
  return null;
@@ -755,8 +838,10 @@ export const approvedBranchSetupIntent = (
755
838
  const intent = branchSetupIntent(entry.data.binding.approvedContent);
756
839
  return (
757
840
  intent !== null &&
841
+ (intent.cwd ?? store.root) === (current.cwd ?? store.root) &&
758
842
  intent.target === current.target &&
759
843
  intent.base === current.base &&
844
+ intent.localBase === current.localBase &&
760
845
  intent.remoteBase === current.remoteBase &&
761
846
  intent.targetExists === current.targetExists
762
847
  );
@@ -820,9 +905,11 @@ export const priorResolvedDrift = (
820
905
  ? "base_branch"
821
906
  : before.remoteBase !== after.remoteBase
822
907
  ? "remote_base"
823
- : before.targetExists !== after.targetExists
824
- ? "target_exists"
825
- : null;
908
+ : before.localBase !== after.localBase
909
+ ? "local_base"
910
+ : before.targetExists !== after.targetExists
911
+ ? "target_exists"
912
+ : null;
826
913
  if (moved === null) return success(null, null, null);
827
914
  return failure(
828
915
  "invalid_input",
@@ -840,10 +927,13 @@ export const priorResolvedDrift = (
840
927
  };
841
928
 
842
929
  export const createAuthorizedExternalActionRunner =
843
- (core: WorkitCore, bind: ExternalActionBinding): ExternalActionEffect =>
930
+ (core: WorkitCore, bind: ExternalActionBinding, coordinationRoot: string): ExternalActionEffect =>
844
931
  async <T>(
845
932
  operation: string,
846
- effect: (step?: string) => T | Result<T> | Promise<T | Result<T>>,
933
+ effect: (
934
+ step?: string,
935
+ reservation?: ActionReservation,
936
+ ) => T | Result<T> | Promise<T | Result<T>>,
847
937
  ) => {
848
938
  let steps: string[] = [];
849
939
  try {
@@ -858,9 +948,21 @@ export const createAuthorizedExternalActionRunner =
858
948
  for (const step of sequence) {
859
949
  const binding = bind(operation);
860
950
  if ("ok" in binding) return binding as ExternalActionResult<T>;
951
+ const targetRoot = externalActionTargetRoot(operation, coordinationRoot);
952
+ if (targetRoot === null)
953
+ return failure("capability_unavailable", "external action target could not be locked", {
954
+ outcome: "not_started",
955
+ });
956
+ const withActionLock: WithExternalActionLock | undefined = !sameDirectoryIdentity(
957
+ targetRoot,
958
+ coordinationRoot,
959
+ )
960
+ ? (run) => new TaskStore(targetRoot).withExternalActionLock(run, true)
961
+ : undefined;
861
962
  const result = await runAuthorizedExternalAction(
862
963
  { core, ...(binding as Omit<AuthorizedActionInput, "core">), ...(step ? { step } : {}) },
863
- () => effect(step),
964
+ (reservation) => effect(step, reservation),
965
+ withActionLock,
864
966
  );
865
967
  latest = result;
866
968
  if (!result.ok) return result;
@@ -868,6 +970,36 @@ export const createAuthorizedExternalActionRunner =
868
970
  return latest ?? failure("recovery_required", "external action did not execute");
869
971
  };
870
972
 
973
+ const externalActionTargetRoot = (operation: string, coordinationRoot: string): string | null => {
974
+ let value: unknown;
975
+ try {
976
+ value = JSON.parse(operation);
977
+ } catch {
978
+ return realpathSync(coordinationRoot);
979
+ }
980
+ if (typeof value !== "object" || value === null) return realpathSync(coordinationRoot);
981
+ const descriptor = value as { operation?: unknown; payload?: unknown };
982
+ if (
983
+ typeof descriptor.operation !== "string" ||
984
+ typeof descriptor.payload !== "object" ||
985
+ descriptor.payload === null
986
+ )
987
+ return realpathSync(coordinationRoot);
988
+ if (
989
+ !descriptor.operation.startsWith("git.") &&
990
+ !descriptor.operation.startsWith("hosting.") &&
991
+ descriptor.operation !== "changelog.apply"
992
+ )
993
+ return realpathSync(coordinationRoot);
994
+ const payload = descriptor.payload as Record<string, unknown>;
995
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : coordinationRoot;
996
+ try {
997
+ return realpathSync(path.resolve(coordinationRoot, cwd));
998
+ } catch {
999
+ return null;
1000
+ }
1001
+ };
1002
+
871
1003
  export type NativeExternalActionObservation = {
872
1004
  actor: string;
873
1005
  actionRef: Ref;
@@ -946,7 +1078,8 @@ export const matchesNativeExternalAction = (
946
1078
  */
947
1079
  export async function runAuthorizedExternalAction<T>(
948
1080
  input: AuthorizedActionInput,
949
- effect: () => T | Result<T> | Promise<T | Result<T>>,
1081
+ effect: (reservation: ActionReservation) => T | Result<T> | Promise<T | Result<T>>,
1082
+ withActionLock?: WithExternalActionLock,
950
1083
  ): Promise<ExternalActionResult<T>> {
951
1084
  if (input.core.isDelegatedCaller())
952
1085
  return failure("permission_denied", "delegated workers cannot run external actions");
@@ -981,7 +1114,7 @@ export async function runAuthorizedExternalAction<T>(
981
1114
  expectedRevision: reserved.data.taskRevision,
982
1115
  expectedWorkspaceRevision: reserved.data.workspaceRevision,
983
1116
  };
984
- return input.core.settleAction({
1117
+ const result = input.core.settleAction({
985
1118
  ...reserved.data,
986
1119
  taskRevision: revisions.expectedRevision,
987
1120
  workspaceRevision: revisions.expectedWorkspaceRevision,
@@ -992,6 +1125,7 @@ export async function runAuthorizedExternalAction<T>(
992
1125
  workspaceRevision: revisions.expectedWorkspaceRevision,
993
1126
  }),
994
1127
  });
1128
+ return result;
995
1129
  } catch {
996
1130
  return failure("external_outcome_unknown", "external action settlement is unavailable", {
997
1131
  operation: "external_action",
@@ -1007,43 +1141,67 @@ export async function runAuthorizedExternalAction<T>(
1007
1141
  ? settle(outcome, true)
1008
1142
  : first;
1009
1143
 
1010
- let effectValue: T | Result<T>;
1144
+ let effectStarted = false;
1145
+ const runEffect = async (): Promise<ExternalActionResult<T>> => {
1146
+ effectStarted = true;
1147
+ let effectValue: T | Result<T>;
1148
+ try {
1149
+ effectValue = await effect(reserved.data);
1150
+ } catch {
1151
+ const settled = settleWithRefresh("unknown", settle("unknown"));
1152
+ if (!settled.ok) return settled as ExternalActionResult<T>;
1153
+ return failure("external_outcome_unknown", "external action outcome is unknown", {
1154
+ operation: "external_action",
1155
+ outcome: "unknown",
1156
+ });
1157
+ }
1158
+ if (
1159
+ typeof effectValue === "object" &&
1160
+ effectValue !== null &&
1161
+ "ok" in effectValue &&
1162
+ typeof (effectValue as { ok?: unknown }).ok === "boolean"
1163
+ ) {
1164
+ const contract = effectValue as Result<T>;
1165
+ if (!contract.ok) {
1166
+ const preflight =
1167
+ typeof contract.details === "object" &&
1168
+ contract.details !== null &&
1169
+ (contract.details as Record<string, unknown>).outcome === "not_started";
1170
+ const outcome = input.failureOutcome ?? (preflight ? "not_started" : "unknown");
1171
+ const settled = settleWithRefresh(outcome, settle(outcome));
1172
+ if (!settled.ok) return settled as ExternalActionResult<T>;
1173
+ return contract;
1174
+ }
1175
+ effectValue = contract.data;
1176
+ }
1177
+ const settled = settleWithRefresh("succeeded", settle("succeeded"));
1178
+ if (!settled.ok) return settled as ExternalActionResult<T>;
1179
+ return {
1180
+ ok: true,
1181
+ schemaVersion: 1,
1182
+ revision: settled.revision,
1183
+ workspaceRevision: settled.workspaceRevision,
1184
+ data: effectValue as T,
1185
+ };
1186
+ };
1187
+ let result: ExternalActionResult<T>;
1011
1188
  try {
1012
- effectValue = await effect();
1189
+ result = withActionLock ? await withActionLock(runEffect) : await runEffect();
1013
1190
  } catch {
1014
- const settled = settleWithRefresh("unknown", settle("unknown"));
1191
+ if (effectStarted)
1192
+ return failure("external_outcome_unknown", "external action outcome is unknown", {
1193
+ operation: "external_action",
1194
+ outcome: "unknown",
1195
+ });
1196
+ const settled = settleWithRefresh("not_started", settle("not_started"));
1015
1197
  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",
1198
+ return failure("writer_conflict", "target checkout was unavailable before the effect", {
1199
+ outcome: "not_started",
1019
1200
  });
1020
1201
  }
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;
1202
+ if (!effectStarted) {
1203
+ const settled = settleWithRefresh("not_started", settle("not_started"));
1204
+ if (!settled.ok) return settled as ExternalActionResult<T>;
1039
1205
  }
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
- };
1206
+ return result;
1049
1207
  }