@webpieces/repo-workflow-core 0.4.957 → 0.4.959

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/repo-workflow-core",
3
- "version": "0.4.957",
3
+ "version": "0.4.959",
4
4
  "description": "Shared repository branch, worktree, sync, and identity-stamp mechanics.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -21,7 +21,7 @@
21
21
  "dependencies": {
22
22
  "inversify": "7.10.4",
23
23
  "reflect-metadata": "0.2.2",
24
- "@webpieces/tooling-common": "0.4.957",
24
+ "@webpieces/tooling-common": "0.4.959",
25
25
  "tslib": "2.8.1"
26
26
  },
27
27
  "publishConfig": {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The ceiling on every external command the detached main-sync refresher runs (#1342).
3
+ *
4
+ * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the
5
+ * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to
6
+ * be the whole of hang recovery, and it was not enough: no `spawnSync` here carried a `timeout`, so a
7
+ * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and
8
+ * after every reclaim the next refresher could stack another one beside it. A bounded command makes a
9
+ * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the
10
+ * backstop for a process that is stuck somewhere no timeout reaches.
11
+ *
12
+ * Sized so ONE network command plus a handful of local ones stays well inside the default
13
+ * 5-minute hang window. Node sends SIGTERM on expiry (the spawnSync default), which lets git clean up
14
+ * its own `.lock` files rather than leaving them behind as SIGKILL would.
15
+ */
16
+ export declare class CommandTimeouts {
17
+ /** `git fetch` and `gh` — the two calls that cross the network. */
18
+ static readonly NETWORK_MS: number;
19
+ /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */
20
+ static readonly LOCAL_GIT_MS: number;
21
+ /** The ceiling for one captured command: `gh` talks to GitHub, everything else here is local git. */
22
+ forCommand(cmd: string): number;
23
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ var CommandTimeouts_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.CommandTimeouts = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const inversify_1 = require("inversify");
7
+ /**
8
+ * The ceiling on every external command the detached main-sync refresher runs (#1342).
9
+ *
10
+ * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the
11
+ * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to
12
+ * be the whole of hang recovery, and it was not enough: no `spawnSync` here carried a `timeout`, so a
13
+ * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and
14
+ * after every reclaim the next refresher could stack another one beside it. A bounded command makes a
15
+ * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the
16
+ * backstop for a process that is stuck somewhere no timeout reaches.
17
+ *
18
+ * Sized so ONE network command plus a handful of local ones stays well inside the default
19
+ * 5-minute hang window. Node sends SIGTERM on expiry (the spawnSync default), which lets git clean up
20
+ * its own `.lock` files rather than leaving them behind as SIGKILL would.
21
+ */
22
+ let CommandTimeouts = class CommandTimeouts {
23
+ static { CommandTimeouts_1 = this; }
24
+ /** `git fetch` and `gh` — the two calls that cross the network. */
25
+ static NETWORK_MS = 90 * 1000;
26
+ /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */
27
+ static LOCAL_GIT_MS = 30 * 1000;
28
+ /** The ceiling for one captured command: `gh` talks to GitHub, everything else here is local git. */
29
+ forCommand(cmd) {
30
+ return cmd === 'gh' ? CommandTimeouts_1.NETWORK_MS : CommandTimeouts_1.LOCAL_GIT_MS;
31
+ }
32
+ };
33
+ exports.CommandTimeouts = CommandTimeouts;
34
+ exports.CommandTimeouts = CommandTimeouts = CommandTimeouts_1 = tslib_1.__decorate([
35
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
36
+ ], CommandTimeouts);
37
+ //# sourceMappingURL=command-timeouts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-timeouts.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/command-timeouts.ts"],"names":[],"mappings":";;;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;GAcG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAe;;IACxB,mEAAmE;IACnE,MAAM,CAAU,UAAU,GAAG,EAAE,GAAG,IAAI,CAAC;IACvC,oHAAoH;IACpH,MAAM,CAAU,YAAY,GAAG,EAAE,GAAG,IAAI,CAAC;IAEzC,qGAAqG;IACrG,UAAU,CAAC,GAAW;QAClB,OAAO,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,iBAAe,CAAC,UAAU,CAAC,CAAC,CAAC,iBAAe,CAAC,YAAY,CAAC;IACpF,CAAC;;AATQ,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,eAAe,CAU3B","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * The ceiling on every external command the detached main-sync refresher runs (#1342).\n *\n * WHY EVERY ONE NEEDS A CEILING. The refresher is single-flight behind `main-sync.lock.json`, and the\n * lock is reclaimed once its holder is older than `hangTimeoutMinutes` (default 5). Reclaiming used to\n * be the whole of hang recovery, and it was not enough: no `spawnSync` here carried a `timeout`, so a\n * `git fetch` stuck on an ssh handshake, or a `gh pr list` stuck on the network, lived forever — and\n * after every reclaim the next refresher could stack another one beside it. A bounded command makes a\n * hung run END on its own; the reclaimer's kill (MainSyncLockService.tryAcquireMainSyncLock) is the\n * backstop for a process that is stuck somewhere no timeout reaches.\n *\n * Sized so ONE network command plus a handful of local ones stays well inside the default\n * 5-minute hang window. Node sends SIGTERM on expiry (the spawnSync default), which lets git clean up\n * its own `.lock` files rather than leaving them behind as SIGKILL would.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CommandTimeouts {\n /** `git fetch` and `gh` — the two calls that cross the network. */\n static readonly NETWORK_MS = 90 * 1000;\n /** Local git (merge-base, rev-list, worktree list, status, …). Generous: a cold, large repo is slow, never hung. */\n static readonly LOCAL_GIT_MS = 30 * 1000;\n\n /** The ceiling for one captured command: `gh` talks to GitHub, everything else here is local git. */\n forCommand(cmd: string): number {\n return cmd === 'gh' ? CommandTimeouts.NETWORK_MS : CommandTimeouts.LOCAL_GIT_MS;\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -12,3 +12,5 @@ export * from './branch-archiver';
12
12
  export * from './branch-mutation-log';
13
13
  export * from './review-identity-stamp';
14
14
  export * from './branch-identity';
15
+ export * from './command-timeouts';
16
+ export * from './main-sync-lock';
package/src/index.js CHANGED
@@ -15,4 +15,6 @@ tslib_1.__exportStar(require("./branch-archiver"), exports);
15
15
  tslib_1.__exportStar(require("./branch-mutation-log"), exports);
16
16
  tslib_1.__exportStar(require("./review-identity-stamp"), exports);
17
17
  tslib_1.__exportStar(require("./branch-identity"), exports);
18
+ tslib_1.__exportStar(require("./command-timeouts"), exports);
19
+ tslib_1.__exportStar(require("./main-sync-lock"), exports);
18
20
  //# sourceMappingURL=index.js.map
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/index.ts"],"names":[],"mappings":";;;AAAA,2DAAiC;AACjC,6DAAmC;AACnC,4DAAkC;AAClC,mEAAyC;AACzC,sDAA4B;AAC5B,0DAAgC;AAChC,4DAAkC;AAClC,gEAAsC;AACtC,mEAAyC;AACzC,mEAAyC;AACzC,4DAAkC;AAClC,gEAAsC;AACtC,kEAAwC;AACxC,4DAAkC","sourcesContent":["export * from './main-sync-file';\nexport * from './main-sync-status';\nexport * from './merged-branches';\nexport * from './merged-branch-verdicts';\nexport * from './worktrees';\nexport * from './branch-reaper';\nexport * from './worktree-reaper';\nexport * from './agent-worktree-lock';\nexport * from './worktree-lock-verdicts';\nexport * from './harness-agent-activity';\nexport * from './branch-archiver';\nexport * from './branch-mutation-log';\nexport * from './review-identity-stamp';\nexport * from './branch-identity';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/index.ts"],"names":[],"mappings":";;;AAAA,2DAAiC;AACjC,6DAAmC;AACnC,4DAAkC;AAClC,mEAAyC;AACzC,sDAA4B;AAC5B,0DAAgC;AAChC,4DAAkC;AAClC,gEAAsC;AACtC,mEAAyC;AACzC,mEAAyC;AACzC,4DAAkC;AAClC,gEAAsC;AACtC,kEAAwC;AACxC,4DAAkC;AAClC,6DAAmC;AACnC,2DAAiC","sourcesContent":["export * from './main-sync-file';\nexport * from './main-sync-status';\nexport * from './merged-branches';\nexport * from './merged-branch-verdicts';\nexport * from './worktrees';\nexport * from './branch-reaper';\nexport * from './worktree-reaper';\nexport * from './agent-worktree-lock';\nexport * from './worktree-lock-verdicts';\nexport * from './harness-agent-activity';\nexport * from './branch-archiver';\nexport * from './branch-mutation-log';\nexport * from './review-identity-stamp';\nexport * from './branch-identity';\nexport * from './command-timeouts';\nexport * from './main-sync-lock';\n"]}
@@ -44,10 +44,27 @@ export declare class MainSyncStatus {
44
44
  forgeReachable: boolean;
45
45
  constructor(branch: string, branchAlreadyMerged: boolean, mergedPr: string, hasForkPoint: boolean, forkPoint: string | null, originMain: string, featureHead: string, conflict: boolean, conflictFiles: string[], timestamp: string);
46
46
  }
47
+ /** How the last detached refresh ended. Recorded in the status file, never in the lock. */
48
+ export declare enum RefreshOutcome {
49
+ OK = "ok",
50
+ ERROR = "error"
51
+ }
47
52
  export declare class MainSyncStatusFile {
48
53
  version: number;
49
54
  timestamp: string;
50
55
  branches: Record<string, MainSyncStatus>;
56
+ /**
57
+ * Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it
58
+ * before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES
59
+ * old. It is the START, not the finish, so a failing run is retried after the interval rather than
60
+ * on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means "due".
61
+ *
62
+ * The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next
63
+ * lock race and still see the previous stamp.
64
+ */
65
+ lastRefreshStartedMs: number | null;
66
+ /** How that run ended; null until a refresh has recorded one. */
67
+ lastRefreshOutcome: RefreshOutcome | null;
51
68
  constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>);
52
69
  }
53
70
  /**
@@ -106,6 +123,12 @@ export declare class MainSyncFileStore {
106
123
  * until the next refresh.
107
124
  */
108
125
  mergeBranch(statusPath: string, status: MainSyncStatus): void;
126
+ /**
127
+ * READ-MODIFY-WRITE the refresh stamp alone, keeping every branch entry. The refresher's failure
128
+ * path: no new branch data, but the run still counts, so the throttle waits out the interval.
129
+ */
130
+ recordRefresh(statusPath: string, startedMs: number, outcome: RefreshOutcome): void;
131
+ private carryRefreshStamp;
109
132
  branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null;
110
133
  /**
111
134
  * Index `gh pr list --state all --json number,headRefName,state` output by head branch.
@@ -116,6 +139,7 @@ export declare class MainSyncFileStore {
116
139
  */
117
140
  indexPullRequests(json: string): PullRequestIndex;
118
141
  private indexOne;
142
+ private toOutcome;
119
143
  private adapt;
120
144
  private toStatus;
121
145
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MainSyncFileStore = exports.PullRequestIndex = exports.MainSyncStatusFile = exports.MainSyncStatus = exports.MAIN_SYNC_STATUS_VERSION = void 0;
3
+ exports.MainSyncFileStore = exports.PullRequestIndex = exports.MainSyncStatusFile = exports.RefreshOutcome = exports.MainSyncStatus = exports.MAIN_SYNC_STATUS_VERSION = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const fs = tslib_1.__importStar(require("fs"));
6
6
  const inversify_1 = require("inversify");
@@ -72,12 +72,30 @@ class MainSyncStatus {
72
72
  }
73
73
  }
74
74
  exports.MainSyncStatus = MainSyncStatus;
75
+ /** How the last detached refresh ended. Recorded in the status file, never in the lock. */
76
+ var RefreshOutcome;
77
+ (function (RefreshOutcome) {
78
+ RefreshOutcome["OK"] = "ok";
79
+ RefreshOutcome["ERROR"] = "error";
80
+ })(RefreshOutcome || (exports.RefreshOutcome = RefreshOutcome = {}));
75
81
  // Data-only. The whole document: every branch the last refresh could see, written as one unit.
76
82
  class MainSyncStatusFile {
77
83
  version;
78
84
  // When the map as a whole was written. Each entry also carries its own timestamp.
79
85
  timestamp;
80
86
  branches;
87
+ /**
88
+ * Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it
89
+ * before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES
90
+ * old. It is the START, not the finish, so a failing run is retried after the interval rather than
91
+ * on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means "due".
92
+ *
93
+ * The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next
94
+ * lock race and still see the previous stamp.
95
+ */
96
+ lastRefreshStartedMs = null;
97
+ /** How that run ended; null until a refresh has recorded one. */
98
+ lastRefreshOutcome = null;
81
99
  constructor(version, timestamp, branches) {
82
100
  this.version = version;
83
101
  this.timestamp = timestamp;
@@ -186,7 +204,28 @@ let MainSyncFileStore = class MainSyncFileStore {
186
204
  const existing = this.readFile(statusPath);
187
205
  const branches = existing === null ? {} : existing.branches;
188
206
  branches[status.branch] = status;
189
- this.writeFile(statusPath, new MainSyncStatusFile(exports.MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches));
207
+ const next = new MainSyncStatusFile(exports.MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);
208
+ this.carryRefreshStamp(existing, next);
209
+ this.writeFile(statusPath, next);
210
+ }
211
+ /**
212
+ * READ-MODIFY-WRITE the refresh stamp alone, keeping every branch entry. The refresher's failure
213
+ * path: no new branch data, but the run still counts, so the throttle waits out the interval.
214
+ */
215
+ recordRefresh(statusPath, startedMs, outcome) {
216
+ const existing = this.readFile(statusPath);
217
+ const next = new MainSyncStatusFile(exports.MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), existing === null ? {} : existing.branches);
218
+ next.lastRefreshStartedMs = startedMs;
219
+ next.lastRefreshOutcome = outcome;
220
+ this.writeFile(statusPath, next);
221
+ }
222
+ // A writer that rebuilds the document (mergeBranch) must not erase the throttle's stamp: losing it
223
+ // would make the very next tool call launch a refresh.
224
+ carryRefreshStamp(from, to) {
225
+ if (from === null)
226
+ return;
227
+ to.lastRefreshStartedMs = from.lastRefreshStartedMs;
228
+ to.lastRefreshOutcome = from.lastRefreshOutcome;
190
229
  }
191
230
  // The entry for one branch, or null when this refresh never saw that branch.
192
231
  branchStatus(file, branch) {
@@ -241,6 +280,13 @@ let MainSyncFileStore = class MainSyncFileStore {
241
280
  if (state === 'OPEN' && !hasOwn(open, branch))
242
281
  open[branch] = number;
243
282
  }
283
+ toOutcome(raw) {
284
+ if (raw === RefreshOutcome.OK)
285
+ return RefreshOutcome.OK;
286
+ if (raw === RefreshOutcome.ERROR)
287
+ return RefreshOutcome.ERROR;
288
+ return null;
289
+ }
244
290
  // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.
245
291
  adapt(raw) {
246
292
  const branches = {};
@@ -250,7 +296,10 @@ let MainSyncFileStore = class MainSyncFileStore {
250
296
  if (entry !== null && typeof entry === 'object')
251
297
  branches[name] = this.toStatus(entry);
252
298
  }
253
- return new MainSyncStatusFile(raw.version ?? exports.MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);
299
+ const file = new MainSyncStatusFile(raw.version ?? exports.MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);
300
+ file.lastRefreshStartedMs = typeof raw.lastRefreshStartedMs === 'number' ? raw.lastRefreshStartedMs : null;
301
+ file.lastRefreshOutcome = this.toOutcome(raw.lastRefreshOutcome);
302
+ return file;
254
303
  }
255
304
  // v1: one bare status at the top level. Key it by the branch it was computed for, so the
256
305
  // guard for THAT branch stays armed across the upgrade and every other branch simply misses
@@ -1 +1 @@
1
- {"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = one bare status; v2 = branch map; v3 = cached commit distance.\nexport const MAIN_SYNC_STATUS_VERSION = 3;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n // Computed by the detached refresher as local main..origin/main. Null means an older cache or git\n // could not establish the distance; stale-main threshold checks fail open in that state.\n commitsBehind: number | null = null;\n /**\n * Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.\n *\n * `branchAlreadyMerged: false` is produced BOTH by \"this branch has no merged PR\" and by \"we could\n * not ask\", and the three guards that act on the merged state took the ordinary ALLOW path in both.\n * Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`\n * instead — the typed verdict exists precisely so abstentions are countable.\n *\n * Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before\n * this field existed (or hand-edited) is read as \"the forge answered\", which preserves today's\n * verdicts exactly rather than retro-labelling old caches as abstentions.\n */\n forgeReachable: boolean = true;\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n /**\n * Did the forge actually ANSWER?\n *\n * `mergedFor()` returning '' has always meant two completely different things — \"this branch has no\n * merged PR\" and \"we could not ask\" (`gh` missing, unauthenticated, rate-limited, offline, or the\n * response unparseable). Both produced the same empty index, so from the decision log you could not\n * tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is\n * the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.\n *\n * REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of\n * `true` would make \"we asked and got an answer\" the thing you get by forgetting.\n */\n forgeReachable: boolean;\n\n constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean) {\n this.merged = merged;\n this.open = open;\n this.forgeReachable = forgeReachable;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n commitsBehind?: number | null;\n forgeReachable?: boolean;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n this.writeFile(statusPath, new MainSyncStatusFile(\n MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches));\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n // Parsed but not an array: `gh` answered with something we do not understand, which is\n // not the same as it answering \"no PRs\". Unreachable, so the guards abstain rather than allow.\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open, false);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return new PullRequestIndex(merged, open, false);\n }\n return new PullRequestIndex(merged, open, true);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n return new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n status.commitsBehind = typeof raw.commitsBehind === 'number'\n && Number.isInteger(raw.commitsBehind) && raw.commitsBehind >= 0\n ? raw.commitsBehind\n : null;\n status.forgeReachable = raw.forgeReachable ?? true;\n return status;\n }\n}\n"]}
1
+ {"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = one bare status; v2 = branch map; v3 = cached commit distance.\nexport const MAIN_SYNC_STATUS_VERSION = 3;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n // Computed by the detached refresher as local main..origin/main. Null means an older cache or git\n // could not establish the distance; stale-main threshold checks fail open in that state.\n commitsBehind: number | null = null;\n /**\n * Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.\n *\n * `branchAlreadyMerged: false` is produced BOTH by \"this branch has no merged PR\" and by \"we could\n * not ask\", and the three guards that act on the merged state took the ordinary ALLOW path in both.\n * Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`\n * instead — the typed verdict exists precisely so abstentions are countable.\n *\n * Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before\n * this field existed (or hand-edited) is read as \"the forge answered\", which preserves today's\n * verdicts exactly rather than retro-labelling old caches as abstentions.\n */\n forgeReachable: boolean = true;\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n/** How the last detached refresh ended. Recorded in the status file, never in the lock. */\nexport enum RefreshOutcome {\n OK = 'ok',\n ERROR = 'error',\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n /**\n * Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it\n * before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES\n * old. It is the START, not the finish, so a failing run is retried after the interval rather than\n * on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means \"due\".\n *\n * The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next\n * lock race and still see the previous stamp.\n */\n lastRefreshStartedMs: number | null = null;\n /** How that run ended; null until a refresh has recorded one. */\n lastRefreshOutcome: RefreshOutcome | null = null;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n /**\n * Did the forge actually ANSWER?\n *\n * `mergedFor()` returning '' has always meant two completely different things — \"this branch has no\n * merged PR\" and \"we could not ask\" (`gh` missing, unauthenticated, rate-limited, offline, or the\n * response unparseable). Both produced the same empty index, so from the decision log you could not\n * tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is\n * the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.\n *\n * REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of\n * `true` would make \"we asked and got an answer\" the thing you get by forgetting.\n */\n forgeReachable: boolean;\n\n constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean) {\n this.merged = merged;\n this.open = open;\n this.forgeReachable = forgeReachable;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n commitsBehind?: number | null;\n forgeReachable?: boolean;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n lastRefreshStartedMs?: number | null;\n lastRefreshOutcome?: string | null;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);\n this.carryRefreshStamp(existing, next);\n this.writeFile(statusPath, next);\n }\n\n /**\n * READ-MODIFY-WRITE the refresh stamp alone, keeping every branch entry. The refresher's failure\n * path: no new branch data, but the run still counts, so the throttle waits out the interval.\n */\n recordRefresh(statusPath: string, startedMs: number, outcome: RefreshOutcome): void {\n const existing = this.readFile(statusPath);\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(),\n existing === null ? {} : existing.branches);\n next.lastRefreshStartedMs = startedMs;\n next.lastRefreshOutcome = outcome;\n this.writeFile(statusPath, next);\n }\n\n // A writer that rebuilds the document (mergeBranch) must not erase the throttle's stamp: losing it\n // would make the very next tool call launch a refresh.\n private carryRefreshStamp(from: MainSyncStatusFile | null, to: MainSyncStatusFile): void {\n if (from === null) return;\n to.lastRefreshStartedMs = from.lastRefreshStartedMs;\n to.lastRefreshOutcome = from.lastRefreshOutcome;\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n // Parsed but not an array: `gh` answered with something we do not understand, which is\n // not the same as it answering \"no PRs\". Unreachable, so the guards abstain rather than allow.\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open, false);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return new PullRequestIndex(merged, open, false);\n }\n return new PullRequestIndex(merged, open, true);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n private toOutcome(raw: string | null | undefined): RefreshOutcome | null {\n if (raw === RefreshOutcome.OK) return RefreshOutcome.OK;\n if (raw === RefreshOutcome.ERROR) return RefreshOutcome.ERROR;\n return null;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n const file = new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n file.lastRefreshStartedMs = typeof raw.lastRefreshStartedMs === 'number' ? raw.lastRefreshStartedMs : null;\n file.lastRefreshOutcome = this.toOutcome(raw.lastRefreshOutcome);\n return file;\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n status.commitsBehind = typeof raw.commitsBehind === 'number'\n && Number.isInteger(raw.commitsBehind) && raw.commitsBehind >= 0\n ? raw.commitsBehind\n : null;\n status.forgeReachable = raw.forgeReachable ?? true;\n return status;\n }\n}\n"]}
@@ -0,0 +1,142 @@
1
+ import { AtomicFile, DotWebpieces } from '@webpieces/tooling-common';
2
+ import { MainSyncStatusService } from './main-sync-status';
3
+ /**
4
+ * The fewest minutes between two refresher STARTS, repo-wide (#1342).
5
+ *
6
+ * The refresh feeds only main-staleness (commits behind) and the merged-PR/reap data, and neither
7
+ * needs minute-level freshness: every `wp-*` command that acts on main does its own fetch. Without a
8
+ * floor the guards started a full refresh (`git fetch` over ssh, `gh pr list --limit 200`, per-branch
9
+ * merge-base) roughly once a minute per repo, and launched a node child on every single tool call.
10
+ *
11
+ * A CONSTANT, not a config knob: it only tunes frequency, and a key would have to wait a release
12
+ * before this repo's own config could state it (the published validator is one release behind).
13
+ */
14
+ export declare const MIN_REFRESH_INTERVAL_MINUTES = 10;
15
+ /**
16
+ * The pid a SPAWNER writes into the lock it takes for a refresher it has not launched yet. The hook
17
+ * process that wins exits within milliseconds, so its own pid would read as "holder dead" and invite a
18
+ * reclaim; 0 reads as "holder unknown", which is judged by age alone and is never signalled. The child
19
+ * replaces it with its own pid the moment it starts (adoptMainSyncLock).
20
+ */
21
+ export declare const SPAWNER_CLAIM_PID = 0;
22
+ export declare const MAIN_SYNC_LOCK_PURPOSE: string;
23
+ /**
24
+ * The WHOLE content of `main-sync.lock.json` (data only). The file is a mutex and nothing else: its
25
+ * EXISTENCE is the lock. `pid` serves the liveness check, the kill of a hung holder and the "is it
26
+ * still mine" check before delete; `startedMs` serves the hangTimeoutMinutes staleness check. No state,
27
+ * no results: those are in main-sync-status.json, written before this file is deleted.
28
+ */
29
+ export declare class MainSyncLock {
30
+ purpose: string;
31
+ pid: number;
32
+ startedMs: number;
33
+ constructor(pid: number, startedMs: number);
34
+ }
35
+ /** Why a spawner did, or did not, win the right to launch a refresher. */
36
+ export declare enum RefreshClaimOutcome {
37
+ /** This caller created the lock and must launch the refresher (which adopts it). */
38
+ CLAIMED = "claimed",
39
+ /** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago. Nothing to do. */
40
+ RECENT = "recent",
41
+ /** The lock exists and its holder is running, or another caller won the create first. */
42
+ HELD = "held"
43
+ }
44
+ export declare class RefreshClaim {
45
+ outcome: RefreshClaimOutcome;
46
+ /** The lock now held; set only for CLAIMED. */
47
+ lock: MainSyncLock | null;
48
+ /** The stamp this decision read from main-sync-status.json, or null when none was recorded. */
49
+ lastRefreshStartedMs: number | null;
50
+ constructor(outcome: RefreshClaimOutcome, lock: MainSyncLock | null, lastRefreshStartedMs: number | null);
51
+ }
52
+ /**
53
+ * The single-flight mutex for the detached main-sync refresher, and the spawner's throttle in front of
54
+ * it (#1342). Two files, two jobs:
55
+ *
56
+ * - `main-sync.lock.json` — the MUTEX. Created with an atomic exclusive create; the creator runs the
57
+ * refresh and deletes it when done, after confirming it is still its own. Everybody else gets
58
+ * EEXIST and skips.
59
+ * - `main-sync-status.json` — the RESULTS, and `lastRefreshStartedMs`, the throttle's stamp. The
60
+ * refresher writes it FIRST and deletes the lock LAST, so the stamp is on disk before any process
61
+ * can win the next race.
62
+ *
63
+ * SHARED scope: one lock for the whole repo, in the primary clone. There is one `.git`, and N
64
+ * concurrent `git fetch`es against it are what corrupt `.git/FETCH_HEAD`.
65
+ */
66
+ export declare class MainSyncLockService {
67
+ private readonly dotDir;
68
+ private readonly atomicFile;
69
+ private readonly status;
70
+ constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile, status?: MainSyncStatusService);
71
+ mainSyncLockPath(repoRoot: string): string;
72
+ /**
73
+ * THE SPAWNER'S WHOLE DECISION: may this hook process launch a refresher? Called BEFORE anything
74
+ * is spawned, so a throttled tool call costs one read of the status file: no node child, no git.
75
+ *
76
+ * The stamp is the last run's START, and a failed run records one too, so a failing refresh
77
+ * (offline, gh auth expired) is retried after the interval: never hammered on every call, never
78
+ * blocked forever. A run that crashes before recording anything leaves its lock behind, and that
79
+ * lock is reclaimed once older than `hangTimeoutMinutes`.
80
+ */
81
+ claimRefresh(repoRoot: string, hangTimeoutMinutes: number, now?: number): RefreshClaim;
82
+ /**
83
+ * Take the mutex with an atomic exclusive create. Returns the lock now held, or null.
84
+ *
85
+ * Normal case: the file does not exist (the last winner deleted it), the create wins, done. On
86
+ * EEXIST the holder is judged: alive and inside `hangTimeoutMinutes` means it is working, and we
87
+ * skip. Otherwise (hung, dead, or a file that is not a lock in this shape at all, such as the
88
+ * retired `state: finished` format) it is the ONE exception to "the winner deletes": its holder is
89
+ * killed if still alive, the file unlinked, and the create retried once; only that create's winner
90
+ * proceeds.
91
+ *
92
+ * The create writes the content to a private temp file and LINKS it into place: `link()` fails with
93
+ * EEXIST exactly like O_EXCL, but the lock appears with its content already in it, so no reader can
94
+ * ever see an empty lock and mistake it for garbage.
95
+ */
96
+ tryAcquireMainSyncLock(repoRoot: string, hangTimeoutMinutes: number, now?: number, pid?: number): MainSyncLock | null;
97
+ /**
98
+ * The detached refresher takes over the lock its spawner created: same `startedMs`, its own pid, so
99
+ * liveness and the reclaimer's kill now target the process doing the work. Null when that lock is no
100
+ * longer on disk (it was reclaimed while the child booted); the child then does nothing. The child
101
+ * never creates the lock itself, so it can never race its own spawner.
102
+ */
103
+ adoptMainSyncLock(repoRoot: string, claimStartedMs: number, pid?: number): MainSyncLock | null;
104
+ /**
105
+ * Delete OUR lock. Returns false, and deletes nothing, when the lock on disk is not ours: a run that
106
+ * hung past its window was reclaimed, and deleting the NEWER holder's lock would let a third
107
+ * refresher start beside the second. Call it only after the status file (and its stamp) is written.
108
+ */
109
+ releaseMainSyncLock(repoRoot: string, held: MainSyncLock): boolean;
110
+ /**
111
+ * The lock on disk, or null when there is none. A file that is not a lock in this shape (the retired
112
+ * `state`/`started` format, or anything unparseable) reads as a lock with pid 0 started at the epoch:
113
+ * stale at once, so it is reclaimed rather than honoured as a second spelling of a lock.
114
+ */
115
+ readMainSyncLock(repoRoot: string): MainSyncLock | null;
116
+ writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
117
+ isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now?: number): boolean;
118
+ isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
119
+ private isHolderRunning;
120
+ private withinInterval;
121
+ private readText;
122
+ private parse;
123
+ /**
124
+ * Atomic create-with-content: write a private temp file, then `link()` it to the lock path. The link
125
+ * fails with EEXIST when the lock exists, which is the O_EXCL contract, and a reader never sees the
126
+ * lock before its content.
127
+ */
128
+ private createExclusive;
129
+ /**
130
+ * Best-effort SIGKILL of a reclaimed holder that is still alive: a refresher hung past
131
+ * `hangTimeoutMinutes`. Never for pid <= 0 (a spawner's unadopted lock, or garbage), our own pid,
132
+ * or the claimant's. The refresher is spawned `detached`, so it leads its own process group: the
133
+ * group is signalled first, which takes its in-flight `git fetch` / `gh` children with it; a plain
134
+ * pid signal is the fallback for a holder that is not a group leader.
135
+ *
136
+ * Residual risk, accepted: after a long hang the OS may have reused the pid. The window is
137
+ * hangTimeoutMinutes, and pids cycle far slower than that on a developer machine.
138
+ */
139
+ private killStaleHolder;
140
+ private sendKill;
141
+ private isProcessAlive;
142
+ }