@bradygaster/squad-sdk 0.9.6-insider.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/client.d.ts.map +1 -1
- package/dist/adapter/client.js +15 -2
- package/dist/adapter/client.js.map +1 -1
- package/dist/adapter/types.d.ts +6 -1
- package/dist/adapter/types.d.ts.map +1 -1
- package/dist/config/init.d.ts.map +1 -1
- package/dist/config/init.js +10 -3
- package/dist/config/init.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/platform/detect.d.ts.map +1 -1
- package/dist/platform/detect.js +7 -0
- package/dist/platform/detect.js.map +1 -1
- package/dist/state-backend.d.ts +154 -9
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +719 -185
- package/dist/state-backend.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +16 -0
- package/dist/tools/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/after-agent-reference.md +2 -2
- package/templates/scribe-charter.md +1 -1
- package/templates/skills/fact-checking/SKILL.md +61 -0
- package/templates/spawn-reference.md +1 -2
- package/templates/squad.agent.md.template +16 -6
- package/templates/workflow-wiring-appendix-a-code-reviewer.md +131 -0
- package/templates/workflow-wiring-appendix-b-documenter.md +140 -0
- package/templates/workflow-wiring-guide.md +276 -0
- package/templates/workflows/squad-heartbeat.yml +167 -167
package/dist/state-backend.js
CHANGED
|
@@ -1,12 +1,277 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Git-native state backends for `.squad/` state storage.
|
|
3
3
|
*
|
|
4
|
+
* Hardening: retry with exponential backoff for transient git errors,
|
|
5
|
+
* circuit-breaker to prevent cascading failures, startup verification,
|
|
6
|
+
* and observable error surfacing (no silent swallowing).
|
|
7
|
+
*
|
|
4
8
|
* @module state-backend
|
|
5
9
|
*/
|
|
6
10
|
import { execFileSync } from 'node:child_process';
|
|
7
11
|
import path from 'node:path';
|
|
8
12
|
import { FSStorageProvider } from './storage/fs-storage-provider.js';
|
|
9
13
|
const storage = new FSStorageProvider();
|
|
14
|
+
// ── Retry configuration ─────────────────────────────────────────────
|
|
15
|
+
const RETRY_MAX = 3;
|
|
16
|
+
const RETRY_BASE_MS = 100;
|
|
17
|
+
const RETRY_MAX_DELAY_MS = 2000;
|
|
18
|
+
/**
|
|
19
|
+
* Buffer ceiling for git stdout/stderr. The Node default is 1 MiB, which is
|
|
20
|
+
* easily blown by `git ls-tree` against large trees or `git notes show` on
|
|
21
|
+
* sizeable JSON blobs — spawnSync then dies with ENOBUFS and the wrapper
|
|
22
|
+
* surfaces it as a generic "git command failed". 256 MiB keeps us safely
|
|
23
|
+
* above any realistic `.squad/` state payload while still capping memory.
|
|
24
|
+
*/
|
|
25
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
26
|
+
// ── Circuit breaker configuration ───────────────────────────────────
|
|
27
|
+
const CIRCUIT_BREAKER_THRESHOLD = 5;
|
|
28
|
+
const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
|
|
29
|
+
/** Classify git stderr as a transient (retryable) failure. */
|
|
30
|
+
function isTransientGitError(stderr) {
|
|
31
|
+
return /unable to access|could not lock|timeout|connection refused|network|SSL|couldn't connect|Another git process|index\.lock/i.test(stderr);
|
|
32
|
+
}
|
|
33
|
+
/** Non-busy synchronous sleep using Atomics. Safe in Node.js 20+. */
|
|
34
|
+
function sleepSync(ms) {
|
|
35
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Execute a git command with retry for transient errors.
|
|
39
|
+
* Throws on failure after exhausting retries.
|
|
40
|
+
*/
|
|
41
|
+
function gitExecWithRetry(args, cwd, trimOutput = true) {
|
|
42
|
+
let lastError;
|
|
43
|
+
for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER });
|
|
46
|
+
return trimOutput ? raw.trim() : raw;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
lastError = err;
|
|
50
|
+
const stderr = err.stderr ?? '';
|
|
51
|
+
if (attempt < RETRY_MAX && isTransientGitError(stderr)) {
|
|
52
|
+
const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
|
|
53
|
+
sleepSync(delay);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw lastError;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Execute a git command with stdin input and retry for transient errors.
|
|
63
|
+
* Throws on failure after exhausting retries.
|
|
64
|
+
*/
|
|
65
|
+
function gitExecWithInputAndRetry(args, cwd, input) {
|
|
66
|
+
let lastError;
|
|
67
|
+
for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync('git', args, { cwd, input, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
lastError = err;
|
|
73
|
+
const stderr = err.stderr ?? '';
|
|
74
|
+
if (attempt < RETRY_MAX && isTransientGitError(stderr)) {
|
|
75
|
+
const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
|
|
76
|
+
sleepSync(delay);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw lastError;
|
|
83
|
+
}
|
|
84
|
+
// ── Typed git errors ────────────────────────────────────────────────
|
|
85
|
+
/** Typed error for git command failures with stderr and command context. */
|
|
86
|
+
export class GitExecError extends Error {
|
|
87
|
+
command;
|
|
88
|
+
reason;
|
|
89
|
+
stderr;
|
|
90
|
+
name = 'GitExecError';
|
|
91
|
+
constructor(command, reason, stderr) {
|
|
92
|
+
super(`git command failed: ${command} — ${reason}`);
|
|
93
|
+
this.command = command;
|
|
94
|
+
this.reason = reason;
|
|
95
|
+
this.stderr = stderr;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Patterns indicating an expected "not found" result from git,
|
|
100
|
+
* as opposed to a real failure (corruption, permission, broken repo).
|
|
101
|
+
*/
|
|
102
|
+
const GIT_EXPECTED_MISSING_RE = /no note found|does not exist in|Not a valid object name|invalid object name|not a tree object|bad default revision|Needed a single revision|unknown revision or path|bad object/i;
|
|
103
|
+
function isExpectedMissing(err) {
|
|
104
|
+
const stderr = err.stderr ?? '';
|
|
105
|
+
const msg = err instanceof Error ? err.message : '';
|
|
106
|
+
return GIT_EXPECTED_MISSING_RE.test(stderr) || GIT_EXPECTED_MISSING_RE.test(msg);
|
|
107
|
+
}
|
|
108
|
+
export class CircuitBreaker {
|
|
109
|
+
threshold;
|
|
110
|
+
cooldownMs;
|
|
111
|
+
state = 'closed';
|
|
112
|
+
failures = 0;
|
|
113
|
+
lastFailureTime = 0;
|
|
114
|
+
constructor(threshold = CIRCUIT_BREAKER_THRESHOLD, cooldownMs = CIRCUIT_BREAKER_COOLDOWN_MS) {
|
|
115
|
+
this.threshold = threshold;
|
|
116
|
+
this.cooldownMs = cooldownMs;
|
|
117
|
+
}
|
|
118
|
+
/** Execute an operation through the circuit breaker. */
|
|
119
|
+
execute(fn, operation) {
|
|
120
|
+
if (this.state === 'open') {
|
|
121
|
+
if (Date.now() - this.lastFailureTime >= this.cooldownMs) {
|
|
122
|
+
this.state = 'half-open';
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw new Error(`Circuit breaker OPEN after ${this.failures} consecutive git failures. ` +
|
|
126
|
+
`Operation '${operation}' rejected. Will retry after ${Math.ceil((this.cooldownMs - (Date.now() - this.lastFailureTime)) / 1000)}s cooldown.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const result = fn();
|
|
131
|
+
this.onSuccess();
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.onFailure();
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
onSuccess() {
|
|
140
|
+
this.failures = 0;
|
|
141
|
+
this.state = 'closed';
|
|
142
|
+
}
|
|
143
|
+
onFailure() {
|
|
144
|
+
this.failures++;
|
|
145
|
+
this.lastFailureTime = Date.now();
|
|
146
|
+
if (this.failures >= this.threshold) {
|
|
147
|
+
this.state = 'open';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
get consecutiveFailures() { return this.failures; }
|
|
151
|
+
get currentState() { return this.state; }
|
|
152
|
+
}
|
|
153
|
+
// ── Git exec helpers (with retry + error classification) ────────────
|
|
154
|
+
/**
|
|
155
|
+
* Execute a git command, returning null for expected absence (e.g., missing ref/path/note).
|
|
156
|
+
* Throws GitExecError for real failures (permission denied, corruption, broken repo).
|
|
157
|
+
* Retries transient errors before classifying.
|
|
158
|
+
*
|
|
159
|
+
* NOTE: `args` is an array, NOT a space-separated string. This was previously a
|
|
160
|
+
* string split on whitespace, which silently mangled any argument containing a
|
|
161
|
+
* space (commit messages, paths with spaces, etc.).
|
|
162
|
+
*/
|
|
163
|
+
function gitExecMaybeMissing(args, cwd, trimOutput = true) {
|
|
164
|
+
try {
|
|
165
|
+
return gitExecWithRetry(args, cwd, trimOutput);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (isExpectedMissing(err))
|
|
169
|
+
return null;
|
|
170
|
+
const stderr = err.stderr ?? '';
|
|
171
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
172
|
+
throw new GitExecError(`git ${args.join(' ')}`, msg, stderr);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Execute a git command that MUST succeed. Throws GitExecError on any failure.
|
|
177
|
+
* Retries transient errors before throwing.
|
|
178
|
+
*
|
|
179
|
+
* NOTE: `args` is an array, NOT a space-separated string (see gitExecMaybeMissing).
|
|
180
|
+
*/
|
|
181
|
+
function gitExecOrThrow(args, cwd) {
|
|
182
|
+
try {
|
|
183
|
+
return gitExecWithRetry(args, cwd);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
const stderr = err.stderr ?? '';
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
throw new GitExecError(`git ${args.join(' ')}`, msg, stderr);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── Optimistic concurrency (compare-and-swap) ───────────────────────
|
|
192
|
+
/** Maximum CAS retry attempts before surfacing as concurrency error. */
|
|
193
|
+
const CAS_MAX_ATTEMPTS = 5;
|
|
194
|
+
/** Base delay for jittered exponential backoff: 50, 100, 200, 400, 800 ms. */
|
|
195
|
+
const CAS_BASE_DELAY_MS = 50;
|
|
196
|
+
/** Git's canonical "ref must not exist" sentinel for update-ref CAS. */
|
|
197
|
+
const GIT_NULL_OID = '0000000000000000000000000000000000000000';
|
|
198
|
+
/**
|
|
199
|
+
* Thrown when an optimistic CAS write (update-ref expected-old) fails after
|
|
200
|
+
* exhausting all retry attempts. Callers may surface, requeue, or retry with
|
|
201
|
+
* application-level coordination. Distinct from GitExecError, which signals
|
|
202
|
+
* a real git failure (corruption, permission, broken repo).
|
|
203
|
+
*/
|
|
204
|
+
export class StateBackendConcurrencyError extends Error {
|
|
205
|
+
operation;
|
|
206
|
+
attempts;
|
|
207
|
+
lastStderr;
|
|
208
|
+
name = 'StateBackendConcurrencyError';
|
|
209
|
+
constructor(operation, attempts, lastStderr) {
|
|
210
|
+
super(`State backend concurrency conflict on '${operation}' after ${attempts} attempts: ${lastStderr || 'ref moved between read and write'}`);
|
|
211
|
+
this.operation = operation;
|
|
212
|
+
this.attempts = attempts;
|
|
213
|
+
this.lastStderr = lastStderr;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Jittered exponential backoff in milliseconds for attempt N (0-indexed):
|
|
218
|
+
* 50, 100, 200, 400, 800 ms base, with ±25% jitter to avoid thundering herd.
|
|
219
|
+
*/
|
|
220
|
+
function jitteredBackoffMs(attempt) {
|
|
221
|
+
const base = CAS_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
222
|
+
const jitter = (Math.random() - 0.5) * 0.5 * base;
|
|
223
|
+
return Math.max(1, Math.round(base + jitter));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Patterns indicating an `update-ref` CAS conflict (retryable) rather than
|
|
227
|
+
* a hard failure. We classify any "ref ... is at ... but expected ..." or
|
|
228
|
+
* lock contention as retryable so the caller can re-read and re-attempt.
|
|
229
|
+
*/
|
|
230
|
+
const GIT_UPDATE_REF_CAS_RE = /is at .* but expected|cannot lock ref|reference already exists|cas_error/i;
|
|
231
|
+
/**
|
|
232
|
+
* Attempt an atomic ref update with compare-and-swap semantics.
|
|
233
|
+
*
|
|
234
|
+
* `expectedOldSha` of `null` means "create only if does not exist"
|
|
235
|
+
* (passed as 40 zeros, git's canonical no-such-ref sentinel).
|
|
236
|
+
*
|
|
237
|
+
* Returns `{ ok: true }` on success, `{ ok: false, stderr }` on CAS conflict,
|
|
238
|
+
* and re-throws any non-CAS git failure (corruption, permission, etc.).
|
|
239
|
+
*/
|
|
240
|
+
function tryUpdateRef(ref, newSha, expectedOldSha, cwd) {
|
|
241
|
+
if (_casInjector) {
|
|
242
|
+
const forced = _casInjector(ref);
|
|
243
|
+
if (forced)
|
|
244
|
+
return forced;
|
|
245
|
+
}
|
|
246
|
+
const expected = expectedOldSha ?? GIT_NULL_OID;
|
|
247
|
+
try {
|
|
248
|
+
execFileSync('git', ['update-ref', ref, newSha, expected], {
|
|
249
|
+
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER,
|
|
250
|
+
});
|
|
251
|
+
return { ok: true, stderr: '' };
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
const stderr = err.stderr ?? '';
|
|
255
|
+
if (GIT_UPDATE_REF_CAS_RE.test(stderr)) {
|
|
256
|
+
return { ok: false, stderr };
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Test-only injector for forcing CAS-conflict / success outcomes deterministically.
|
|
263
|
+
* Production callers never set this. @internal
|
|
264
|
+
*/
|
|
265
|
+
let _casInjector = null;
|
|
266
|
+
export function _setCasInjectorForTesting(fn) {
|
|
267
|
+
_casInjector = fn;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Internal CAS primitive — exported for unit tests only.
|
|
271
|
+
* @internal
|
|
272
|
+
*/
|
|
273
|
+
export const _tryUpdateRefForTesting = tryUpdateRef;
|
|
274
|
+
// ── Backends ────────────────────────────────────────────────────────
|
|
10
275
|
export class WorktreeBackend {
|
|
11
276
|
name = 'local';
|
|
12
277
|
root;
|
|
@@ -47,23 +312,6 @@ export class WorktreeBackend {
|
|
|
47
312
|
storage.appendSync(path.join(this.root, key), content);
|
|
48
313
|
}
|
|
49
314
|
}
|
|
50
|
-
function gitExec(args, cwd) {
|
|
51
|
-
try {
|
|
52
|
-
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function gitExecWithInput(args, input, cwd) {
|
|
59
|
-
return execFileSync('git', args, { cwd, input, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
60
|
-
}
|
|
61
|
-
function gitExecOrThrow(args, cwd) {
|
|
62
|
-
const result = gitExec(args, cwd);
|
|
63
|
-
if (result === null)
|
|
64
|
-
throw new Error(`git command failed: git ${args.join(' ')}`);
|
|
65
|
-
return result;
|
|
66
|
-
}
|
|
67
315
|
/**
|
|
68
316
|
* Validate a state key against characters that could corrupt git plumbing
|
|
69
317
|
* input (mktree stdin format, branch:path refs) or cause path confusion.
|
|
@@ -103,26 +351,36 @@ export class GitNotesBackend {
|
|
|
103
351
|
name = 'git-notes';
|
|
104
352
|
cwd;
|
|
105
353
|
ref = 'squad';
|
|
106
|
-
|
|
354
|
+
breaker = new CircuitBreaker();
|
|
355
|
+
_rootCommit;
|
|
107
356
|
constructor(repoRoot) { this.cwd = repoRoot; }
|
|
357
|
+
/** Returns the root commit SHA — a stable anchor that never moves. Cached after first call. */
|
|
358
|
+
rootCommit() {
|
|
359
|
+
if (!this._rootCommit) {
|
|
360
|
+
this._rootCommit = gitExecOrThrow(['rev-list', '--max-parents=0', 'HEAD'], this.cwd);
|
|
361
|
+
}
|
|
362
|
+
return this._rootCommit;
|
|
363
|
+
}
|
|
364
|
+
/** Resolve the current SHA of refs/notes/<ref>, or null if it doesn't exist. */
|
|
365
|
+
readNotesRef() {
|
|
366
|
+
return gitExecMaybeMissing(['rev-parse', '--verify', `refs/notes/${this.ref}`], this.cwd);
|
|
367
|
+
}
|
|
108
368
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
369
|
+
* Load the JSON blob attached to the root commit at a SPECIFIC notes ref SHA.
|
|
370
|
+
* Reading at a pinned SHA (not the live ref tip) is the foundation of the CAS
|
|
371
|
+
* loop — without it, a writer could observe state at version N, build version
|
|
372
|
+
* N+1, but race against another writer who already advanced to N+1' (losing
|
|
373
|
+
* data). With a pinned read, the subsequent update-ref CAS catches the race.
|
|
374
|
+
*
|
|
375
|
+
* NOTE: this relies on the notes tree having no fanout. Git uses fanout
|
|
376
|
+
* (ab/cdef.../) only when many notes are present; we only ever store a single
|
|
377
|
+
* note (on the root commit), so the path is just `<refSha>:<anchor>`.
|
|
112
378
|
*/
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
return
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
throw new Error('git-notes backend: no root commit found');
|
|
119
|
-
// If multiple roots (e.g. from unrelated-history merges), use the first.
|
|
120
|
-
this.cachedAnchor = root.split('\n')[0].trim();
|
|
121
|
-
return this.cachedAnchor;
|
|
122
|
-
}
|
|
123
|
-
loadBlob() {
|
|
124
|
-
const anchor = this.getAnchorCommit();
|
|
125
|
-
const raw = gitExec(['notes', `--ref=${this.ref}`, 'show', anchor], this.cwd);
|
|
379
|
+
loadBlobAt(refSha) {
|
|
380
|
+
if (!refSha)
|
|
381
|
+
return {};
|
|
382
|
+
const anchor = this.rootCommit();
|
|
383
|
+
const raw = gitExecMaybeMissing(['show', `${refSha}:${anchor}`], this.cwd, false);
|
|
126
384
|
if (!raw)
|
|
127
385
|
return {};
|
|
128
386
|
try {
|
|
@@ -136,168 +394,254 @@ export class GitNotesBackend {
|
|
|
136
394
|
return {};
|
|
137
395
|
}
|
|
138
396
|
}
|
|
139
|
-
|
|
140
|
-
|
|
397
|
+
/** Convenience reader at the live ref tip (used for read-only operations). */
|
|
398
|
+
loadBlob() {
|
|
399
|
+
return this.loadBlobAt(this.readNotesRef());
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Build a new notes commit and attempt to atomically swing refs/notes/<ref>
|
|
403
|
+
* from `expectedOldRefSha` to it. Returns the same `{ ok, stderr }` shape as
|
|
404
|
+
* tryUpdateRef so the caller's retry loop can act.
|
|
405
|
+
*/
|
|
406
|
+
atomicSaveBlob(blob, expectedOldRefSha) {
|
|
407
|
+
const anchor = this.rootCommit();
|
|
141
408
|
const json = JSON.stringify(blob, null, 2);
|
|
409
|
+
let blobSha;
|
|
410
|
+
let treeSha;
|
|
411
|
+
let commitSha;
|
|
142
412
|
try {
|
|
143
|
-
|
|
413
|
+
blobSha = gitExecWithInputAndRetry(['hash-object', '-w', '--stdin'], this.cwd, json);
|
|
414
|
+
treeSha = gitExecWithInputAndRetry(['mktree'], this.cwd, `100644 blob ${blobSha}\t${anchor}\n`);
|
|
415
|
+
const parentArgs = expectedOldRefSha ? ['-p', expectedOldRefSha] : [];
|
|
416
|
+
commitSha = gitExecWithRetry(['commit-tree', treeSha, ...parentArgs, '-m', 'Update squad state'], this.cwd);
|
|
144
417
|
}
|
|
145
|
-
catch {
|
|
146
|
-
|
|
418
|
+
catch (err) {
|
|
419
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
420
|
+
throw new Error(`git-notes backend: failed to build notes commit — ${msg}`);
|
|
147
421
|
}
|
|
422
|
+
return tryUpdateRef(`refs/notes/${this.ref}`, commitSha, expectedOldRefSha, this.cwd);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Run a mutator under optimistic CAS. The mutator receives the current blob
|
|
426
|
+
* (re-read on every attempt) and may mutate it; its return value is forwarded
|
|
427
|
+
* to the caller on success. On CAS conflict, the loop retries with jittered
|
|
428
|
+
* backoff up to CAS_MAX_ATTEMPTS times, then throws StateBackendConcurrencyError.
|
|
429
|
+
*/
|
|
430
|
+
mutateBlob(operation, mutator) {
|
|
431
|
+
let lastStderr = '';
|
|
432
|
+
for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
|
|
433
|
+
const oldRefSha = this.readNotesRef();
|
|
434
|
+
const blob = this.loadBlobAt(oldRefSha);
|
|
435
|
+
const result = mutator(blob);
|
|
436
|
+
const writeResult = this.atomicSaveBlob(blob, oldRefSha);
|
|
437
|
+
if (writeResult.ok)
|
|
438
|
+
return result;
|
|
439
|
+
lastStderr = writeResult.stderr;
|
|
440
|
+
if (attempt < CAS_MAX_ATTEMPTS - 1) {
|
|
441
|
+
sleepSync(jitteredBackoffMs(attempt));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
throw new StateBackendConcurrencyError(operation, CAS_MAX_ATTEMPTS, lastStderr);
|
|
148
445
|
}
|
|
149
446
|
read(relativePath) {
|
|
150
|
-
|
|
151
|
-
|
|
447
|
+
return this.breaker.execute(() => {
|
|
448
|
+
const blob = this.loadBlob();
|
|
449
|
+
return blob[normalizeKey(relativePath)];
|
|
450
|
+
}, `git-notes:read(${relativePath})`);
|
|
152
451
|
}
|
|
153
452
|
write(relativePath, content) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
453
|
+
this.breaker.execute(() => {
|
|
454
|
+
this.mutateBlob(`git-notes:write(${relativePath})`, (blob) => {
|
|
455
|
+
blob[normalizeKey(relativePath)] = content;
|
|
456
|
+
});
|
|
457
|
+
}, `git-notes:write(${relativePath})`);
|
|
157
458
|
}
|
|
158
459
|
exists(relativePath) {
|
|
159
|
-
return Object.hasOwn(this.loadBlob(), normalizeKey(relativePath));
|
|
460
|
+
return this.breaker.execute(() => Object.hasOwn(this.loadBlob(), normalizeKey(relativePath)), `git-notes:exists(${relativePath})`);
|
|
160
461
|
}
|
|
161
462
|
list(relativeDir) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
463
|
+
return this.breaker.execute(() => {
|
|
464
|
+
const blob = this.loadBlob();
|
|
465
|
+
const normalized = normalizeKey(relativeDir);
|
|
466
|
+
const dirPrefix = normalized ? normalized + '/' : '';
|
|
467
|
+
const entries = new Set();
|
|
468
|
+
for (const key of Object.keys(blob)) {
|
|
469
|
+
if (key.startsWith(dirPrefix)) {
|
|
470
|
+
const rest = key.slice(dirPrefix.length);
|
|
471
|
+
const slash = rest.indexOf('/');
|
|
472
|
+
entries.add(slash === -1 ? rest : rest.slice(0, slash));
|
|
473
|
+
}
|
|
171
474
|
}
|
|
172
|
-
|
|
173
|
-
|
|
475
|
+
return [...entries].sort();
|
|
476
|
+
}, `git-notes:list(${relativeDir})`);
|
|
174
477
|
}
|
|
175
478
|
delete(relativePath) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
479
|
+
return this.breaker.execute(() => {
|
|
480
|
+
const key = normalizeKey(relativePath);
|
|
481
|
+
return this.mutateBlob(`git-notes:delete(${relativePath})`, (blob) => {
|
|
482
|
+
if (!Object.hasOwn(blob, key))
|
|
483
|
+
return false;
|
|
484
|
+
delete blob[key];
|
|
485
|
+
return true;
|
|
486
|
+
});
|
|
487
|
+
}, `git-notes:delete(${relativePath})`);
|
|
183
488
|
}
|
|
184
489
|
append(relativePath, content) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
490
|
+
this.breaker.execute(() => {
|
|
491
|
+
this.mutateBlob(`git-notes:append(${relativePath})`, (blob) => {
|
|
492
|
+
const key = normalizeKey(relativePath);
|
|
493
|
+
blob[key] = (blob[key] ?? '') + content;
|
|
494
|
+
});
|
|
495
|
+
}, `git-notes:append(${relativePath})`);
|
|
189
496
|
}
|
|
190
497
|
}
|
|
191
498
|
export class OrphanBranchBackend {
|
|
192
499
|
name = 'orphan';
|
|
193
500
|
cwd;
|
|
194
501
|
branch;
|
|
502
|
+
breaker = new CircuitBreaker();
|
|
195
503
|
constructor(repoRoot, branch = 'squad-state') {
|
|
196
504
|
this.cwd = repoRoot;
|
|
197
505
|
this.branch = branch;
|
|
198
506
|
}
|
|
199
507
|
ensureBranch() {
|
|
200
|
-
if (
|
|
508
|
+
if (gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd))
|
|
201
509
|
return;
|
|
202
510
|
let tree;
|
|
203
511
|
try {
|
|
204
|
-
tree =
|
|
512
|
+
tree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
|
|
205
513
|
}
|
|
206
|
-
catch {
|
|
207
|
-
|
|
514
|
+
catch (err) {
|
|
515
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
516
|
+
throw new Error(`orphan backend: failed to create empty tree — ${msg}`);
|
|
208
517
|
}
|
|
209
518
|
let commit;
|
|
210
519
|
try {
|
|
211
|
-
commit =
|
|
212
|
-
cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
213
|
-
}).trim();
|
|
520
|
+
commit = gitExecWithRetry(['commit-tree', tree, '-m', 'Initialize squad-state branch'], this.cwd);
|
|
214
521
|
}
|
|
215
|
-
catch {
|
|
216
|
-
|
|
522
|
+
catch (err) {
|
|
523
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
524
|
+
throw new Error(`orphan backend: failed to create initial commit — ${msg}`);
|
|
525
|
+
}
|
|
526
|
+
// CAS create: succeeds only if the ref still doesn't exist. If a concurrent
|
|
527
|
+
// writer created it between our check and now, fall through silently — the
|
|
528
|
+
// caller's mutation loop will pick up the new head on its next iteration.
|
|
529
|
+
const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, commit, null, this.cwd);
|
|
530
|
+
if (!writeResult.ok) {
|
|
531
|
+
// Re-verify someone else created it; if so, we're done.
|
|
532
|
+
if (gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd))
|
|
533
|
+
return;
|
|
534
|
+
throw new Error(`orphan backend: failed to initialize branch — ${writeResult.stderr}`);
|
|
217
535
|
}
|
|
218
|
-
gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, commit], this.cwd);
|
|
219
536
|
}
|
|
220
537
|
read(relativePath) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return undefined;
|
|
229
|
-
}
|
|
538
|
+
return this.breaker.execute(() => {
|
|
539
|
+
const result = gitExecMaybeMissing(['show', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd, false);
|
|
540
|
+
return result ?? undefined;
|
|
541
|
+
}, `orphan:read(${relativePath})`);
|
|
230
542
|
}
|
|
231
543
|
write(relativePath, content) {
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
blobHash
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
throw new Error(`orphan backend: failed to hash content for ${key}`);
|
|
240
|
-
}
|
|
241
|
-
let currentTree;
|
|
242
|
-
const treeResult = gitExec(['log', '--format=%T', '-1', this.branch], this.cwd);
|
|
243
|
-
if (!treeResult) {
|
|
544
|
+
this.breaker.execute(() => {
|
|
545
|
+
this.ensureBranch();
|
|
546
|
+
const key = normalizeKey(relativePath);
|
|
547
|
+
// Blob is content-addressed, so hash once outside the CAS loop.
|
|
548
|
+
let blobHash;
|
|
244
549
|
try {
|
|
245
|
-
|
|
550
|
+
blobHash = gitExecWithInputAndRetry(['hash-object', '-w', '--stdin'], this.cwd, content);
|
|
246
551
|
}
|
|
247
|
-
catch {
|
|
248
|
-
|
|
552
|
+
catch (err) {
|
|
553
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
554
|
+
throw new Error(`orphan backend: failed to hash content for ${key} — ${msg}`);
|
|
249
555
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
556
|
+
let lastStderr = '';
|
|
557
|
+
for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
|
|
558
|
+
// Re-read the ref every iteration so we rebuild on top of the latest tree.
|
|
559
|
+
const parentCommit = gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd);
|
|
560
|
+
let currentTree;
|
|
561
|
+
if (parentCommit) {
|
|
562
|
+
const treeResult = gitExecMaybeMissing(['rev-parse', `${parentCommit}^{tree}`], this.cwd);
|
|
563
|
+
currentTree = treeResult ?? gitExecWithInputAndRetry(['mktree'], this.cwd, '');
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
try {
|
|
567
|
+
currentTree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
571
|
+
throw new Error(`orphan backend: failed to create empty tree — ${msg}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const newTree = this.updateTree(currentTree, key.split('/'), blobHash);
|
|
575
|
+
let newCommit;
|
|
576
|
+
try {
|
|
577
|
+
const parentArgs = parentCommit ? ['-p', parentCommit] : [];
|
|
578
|
+
newCommit = gitExecWithRetry(['commit-tree', newTree, ...parentArgs, '-m', `Update ${key}`], this.cwd);
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
+
throw new Error(`orphan backend: failed to commit update for ${key} — ${msg}`);
|
|
583
|
+
}
|
|
584
|
+
const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, newCommit, parentCommit, this.cwd);
|
|
585
|
+
if (writeResult.ok)
|
|
586
|
+
return;
|
|
587
|
+
lastStderr = writeResult.stderr;
|
|
588
|
+
if (attempt < CAS_MAX_ATTEMPTS - 1) {
|
|
589
|
+
sleepSync(jitteredBackoffMs(attempt));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
throw new StateBackendConcurrencyError(`orphan:write(${relativePath})`, CAS_MAX_ATTEMPTS, lastStderr);
|
|
593
|
+
}, `orphan:write(${relativePath})`);
|
|
267
594
|
}
|
|
268
595
|
exists(relativePath) {
|
|
269
|
-
return
|
|
596
|
+
return this.breaker.execute(() => gitExecMaybeMissing(['cat-file', '-t', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd) !== null, `orphan:exists(${relativePath})`);
|
|
270
597
|
}
|
|
271
598
|
list(relativeDir) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
599
|
+
return this.breaker.execute(() => {
|
|
600
|
+
const key = normalizeKey(relativeDir);
|
|
601
|
+
const target = key ? `${this.branch}:${key}` : `${this.branch}:`;
|
|
602
|
+
const result = gitExecMaybeMissing(['ls-tree', '--name-only', target], this.cwd);
|
|
603
|
+
if (!result)
|
|
604
|
+
return [];
|
|
605
|
+
return result.split('\n').filter(Boolean);
|
|
606
|
+
}, `orphan:list(${relativeDir})`);
|
|
278
607
|
}
|
|
279
608
|
delete(relativePath) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
609
|
+
return this.breaker.execute(() => {
|
|
610
|
+
const key = normalizeKey(relativePath);
|
|
611
|
+
if (gitExecMaybeMissing(['cat-file', '-t', `${this.branch}:${key}`], this.cwd) === null)
|
|
612
|
+
return false;
|
|
613
|
+
this.ensureBranch();
|
|
614
|
+
let lastStderr = '';
|
|
615
|
+
for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
|
|
616
|
+
const parentCommit = gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd);
|
|
617
|
+
if (!parentCommit)
|
|
618
|
+
return false;
|
|
619
|
+
const treeResult = gitExecMaybeMissing(['rev-parse', `${parentCommit}^{tree}`], this.cwd);
|
|
620
|
+
if (!treeResult)
|
|
621
|
+
return false;
|
|
622
|
+
// Re-check existence at the freshly-read tree — a concurrent delete may
|
|
623
|
+
// have already removed our key, in which case there's nothing to do.
|
|
624
|
+
if (gitExecMaybeMissing(['cat-file', '-t', `${parentCommit}:${key}`], this.cwd) === null)
|
|
625
|
+
return false;
|
|
626
|
+
const newTree = this.removeFromTree(treeResult, key.split('/'));
|
|
627
|
+
let newCommit;
|
|
628
|
+
try {
|
|
629
|
+
newCommit = gitExecWithRetry(['commit-tree', newTree, '-p', parentCommit, '-m', `Delete ${key}`], this.cwd);
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
633
|
+
throw new Error(`orphan backend: failed to commit delete for ${key} — ${msg}`);
|
|
634
|
+
}
|
|
635
|
+
const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, newCommit, parentCommit, this.cwd);
|
|
636
|
+
if (writeResult.ok)
|
|
637
|
+
return true;
|
|
638
|
+
lastStderr = writeResult.stderr;
|
|
639
|
+
if (attempt < CAS_MAX_ATTEMPTS - 1) {
|
|
640
|
+
sleepSync(jitteredBackoffMs(attempt));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
throw new StateBackendConcurrencyError(`orphan:delete(${relativePath})`, CAS_MAX_ATTEMPTS, lastStderr);
|
|
644
|
+
}, `orphan:delete(${relativePath})`);
|
|
301
645
|
}
|
|
302
646
|
append(relativePath, content) {
|
|
303
647
|
const existing = this.read(relativePath) ?? '';
|
|
@@ -307,40 +651,39 @@ export class OrphanBranchBackend {
|
|
|
307
651
|
if (pathSegments.length === 0)
|
|
308
652
|
throw new Error('orphan backend: empty path segments');
|
|
309
653
|
if (pathSegments.length === 1) {
|
|
310
|
-
|
|
311
|
-
const listing = gitExec(['ls-tree', baseTree], this.cwd) ?? '';
|
|
654
|
+
const listing = gitExecMaybeMissing(['ls-tree', baseTree], this.cwd) ?? '';
|
|
312
655
|
const lines = listing.split('\n').filter(Boolean);
|
|
313
656
|
const filtered = lines.filter((line) => {
|
|
314
657
|
const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
|
|
315
658
|
return !(match && match[4] === pathSegments[0]);
|
|
316
659
|
});
|
|
317
660
|
try {
|
|
318
|
-
return
|
|
661
|
+
return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.length > 0 ? filtered.join('\n') + '\n' : '');
|
|
319
662
|
}
|
|
320
|
-
catch {
|
|
321
|
-
|
|
663
|
+
catch (err) {
|
|
664
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
665
|
+
throw new Error(`orphan backend: failed to remove entry ${pathSegments[0]} — ${msg}`);
|
|
322
666
|
}
|
|
323
667
|
}
|
|
324
668
|
const [dir, ...rest] = pathSegments;
|
|
325
669
|
const subTreeHash = this.getSubtreeHash(baseTree, dir);
|
|
326
670
|
if (!subTreeHash)
|
|
327
|
-
return baseTree;
|
|
671
|
+
return baseTree;
|
|
328
672
|
const childTree = this.removeFromTree(subTreeHash, rest);
|
|
329
|
-
|
|
330
|
-
const childListing = gitExec(['ls-tree', childTree], this.cwd);
|
|
673
|
+
const childListing = gitExecMaybeMissing(['ls-tree', childTree], this.cwd);
|
|
331
674
|
if (!childListing || childListing.length === 0) {
|
|
332
|
-
|
|
333
|
-
const listing = gitExec(['ls-tree', baseTree], this.cwd) ?? '';
|
|
675
|
+
const listing = gitExecMaybeMissing(['ls-tree', baseTree], this.cwd) ?? '';
|
|
334
676
|
const lines = listing.split('\n').filter(Boolean);
|
|
335
677
|
const filtered = lines.filter((line) => {
|
|
336
678
|
const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
|
|
337
679
|
return !(match && match[4] === dir);
|
|
338
680
|
});
|
|
339
681
|
try {
|
|
340
|
-
return
|
|
682
|
+
return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.length > 0 ? filtered.join('\n') + '\n' : '');
|
|
341
683
|
}
|
|
342
|
-
catch {
|
|
343
|
-
|
|
684
|
+
catch (err) {
|
|
685
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
686
|
+
throw new Error(`orphan backend: failed to prune empty directory ${dir} — ${msg}`);
|
|
344
687
|
}
|
|
345
688
|
}
|
|
346
689
|
return this.replaceEntry(baseTree, dir, '040000', 'tree', childTree);
|
|
@@ -358,13 +701,13 @@ export class OrphanBranchBackend {
|
|
|
358
701
|
childTree = this.updateTree(subTreeHash, rest, blobHash);
|
|
359
702
|
}
|
|
360
703
|
else {
|
|
361
|
-
const emptyTree =
|
|
704
|
+
const emptyTree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
|
|
362
705
|
childTree = this.updateTree(emptyTree, rest, blobHash);
|
|
363
706
|
}
|
|
364
707
|
return this.replaceEntry(baseTree, dir, '040000', 'tree', childTree);
|
|
365
708
|
}
|
|
366
709
|
getSubtreeHash(treeHash, name) {
|
|
367
|
-
const listing =
|
|
710
|
+
const listing = gitExecMaybeMissing(['ls-tree', treeHash], this.cwd);
|
|
368
711
|
if (!listing)
|
|
369
712
|
return null;
|
|
370
713
|
for (const line of listing.split('\n')) {
|
|
@@ -375,7 +718,7 @@ export class OrphanBranchBackend {
|
|
|
375
718
|
return null;
|
|
376
719
|
}
|
|
377
720
|
replaceEntry(treeHash, name, mode, type, hash) {
|
|
378
|
-
const listing =
|
|
721
|
+
const listing = gitExecMaybeMissing(['ls-tree', treeHash], this.cwd) ?? '';
|
|
379
722
|
const lines = listing.split('\n').filter(Boolean);
|
|
380
723
|
const filtered = lines.filter((line) => {
|
|
381
724
|
const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
|
|
@@ -383,10 +726,11 @@ export class OrphanBranchBackend {
|
|
|
383
726
|
});
|
|
384
727
|
filtered.push(`${mode} ${type} ${hash}\t${name}`);
|
|
385
728
|
try {
|
|
386
|
-
return
|
|
729
|
+
return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.join('\n') + '\n');
|
|
387
730
|
}
|
|
388
|
-
catch {
|
|
389
|
-
|
|
731
|
+
catch (err) {
|
|
732
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
733
|
+
throw new Error(`orphan backend: failed to create tree with entry ${name} — ${msg}`);
|
|
390
734
|
}
|
|
391
735
|
}
|
|
392
736
|
}
|
|
@@ -492,16 +836,29 @@ export class StateBackendStorageAdapter {
|
|
|
492
836
|
}
|
|
493
837
|
/** Convert absolute path to relative path for the backend. */
|
|
494
838
|
toRelative(filePath) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
839
|
+
// Use path.resolve() so drive-letter casing differences on Windows are
|
|
840
|
+
// normalised before comparison, preventing corrupt git-notes keys.
|
|
841
|
+
const resolvedFile = path.resolve(filePath);
|
|
842
|
+
const resolvedSquad = path.resolve(this.squadDir);
|
|
843
|
+
const isWindows = process.platform === 'win32';
|
|
844
|
+
const fileCmp = isWindows ? resolvedFile.toLowerCase() : resolvedFile;
|
|
845
|
+
const squadCmp = isWindows ? resolvedSquad.toLowerCase() : resolvedSquad;
|
|
846
|
+
const prefix = squadCmp.endsWith(path.sep) ? squadCmp : squadCmp + path.sep;
|
|
847
|
+
if (fileCmp.startsWith(prefix)) {
|
|
848
|
+
return resolvedFile.slice(resolvedSquad.length + 1).replace(/\\/g, '/');
|
|
499
849
|
}
|
|
500
|
-
if (
|
|
501
|
-
return
|
|
850
|
+
if (fileCmp === squadCmp) {
|
|
851
|
+
return '.';
|
|
502
852
|
}
|
|
503
|
-
//
|
|
504
|
-
|
|
853
|
+
// If the path is already relative (no drive letter or leading sep), normalise and return.
|
|
854
|
+
if (!path.isAbsolute(filePath)) {
|
|
855
|
+
return filePath.replace(/\\/g, '/');
|
|
856
|
+
}
|
|
857
|
+
// Absolute path that doesn't live under squadDir — this would produce a
|
|
858
|
+
// corrupt git-notes key (absolute path leaking into the ref namespace).
|
|
859
|
+
throw new Error(`[squad] toRelative: path is outside squadDir and cannot be used as a state key.\n` +
|
|
860
|
+
` path: ${resolvedFile}\n` +
|
|
861
|
+
` squadDir: ${resolvedSquad}`);
|
|
505
862
|
}
|
|
506
863
|
}
|
|
507
864
|
/**
|
|
@@ -510,13 +867,19 @@ export class StateBackendStorageAdapter {
|
|
|
510
867
|
* - Git notes for commit-scoped "why" annotations (per-agent namespace)
|
|
511
868
|
* - Orphan branch for permanent state (decisions, histories, logs)
|
|
512
869
|
*
|
|
513
|
-
*
|
|
870
|
+
* The notes layer is a real, callable consumer in this backend: call
|
|
871
|
+
* {@link TwoLayerBackend.promoteNotes} after a PR merges to move notes flagged
|
|
872
|
+
* with `promote_to_permanent` into the orphan store, and copy notes flagged
|
|
873
|
+
* with `archive_on_close` into `archive/`. {@link TwoLayerBackend.readNote}
|
|
874
|
+
* returns a single note's payload.
|
|
514
875
|
*/
|
|
515
876
|
export class TwoLayerBackend {
|
|
516
877
|
name = 'two-layer';
|
|
517
878
|
notes;
|
|
518
879
|
orphan;
|
|
880
|
+
repoRoot;
|
|
519
881
|
constructor(repoRoot) {
|
|
882
|
+
this.repoRoot = repoRoot;
|
|
520
883
|
this.notes = new GitNotesBackend(repoRoot);
|
|
521
884
|
this.orphan = new OrphanBranchBackend(repoRoot);
|
|
522
885
|
}
|
|
@@ -530,7 +893,10 @@ export class TwoLayerBackend {
|
|
|
530
893
|
try {
|
|
531
894
|
this.notes.write(key, value);
|
|
532
895
|
}
|
|
533
|
-
catch {
|
|
896
|
+
catch (err) {
|
|
897
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
898
|
+
console.warn(`[two-layer] notes write failed for ${key}: ${msg}`);
|
|
899
|
+
}
|
|
534
900
|
}
|
|
535
901
|
list(dir) {
|
|
536
902
|
return this.orphan.list(dir);
|
|
@@ -543,7 +909,10 @@ export class TwoLayerBackend {
|
|
|
543
909
|
try {
|
|
544
910
|
this.notes.delete(key);
|
|
545
911
|
}
|
|
546
|
-
catch {
|
|
912
|
+
catch (err) {
|
|
913
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
914
|
+
console.warn(`[two-layer] notes delete failed for ${key}: ${msg}`);
|
|
915
|
+
}
|
|
547
916
|
return result;
|
|
548
917
|
}
|
|
549
918
|
append(key, value) {
|
|
@@ -551,7 +920,123 @@ export class TwoLayerBackend {
|
|
|
551
920
|
try {
|
|
552
921
|
this.notes.append(key, value);
|
|
553
922
|
}
|
|
554
|
-
catch {
|
|
923
|
+
catch (err) {
|
|
924
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
925
|
+
console.warn(`[two-layer] notes append failed for ${key}: ${msg}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Read a single git-notes payload as parsed JSON.
|
|
930
|
+
*
|
|
931
|
+
* Returns `null` if no note exists on the given commit for the given ref,
|
|
932
|
+
* or if the note body is not valid JSON.
|
|
933
|
+
*/
|
|
934
|
+
readNote(ref, commitSha) {
|
|
935
|
+
if (!this.isSafeRef(ref) || !this.isSafeCommitSha(commitSha))
|
|
936
|
+
return null;
|
|
937
|
+
const raw = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'show', commitSha], this.repoRoot, false);
|
|
938
|
+
if (raw === null)
|
|
939
|
+
return null;
|
|
940
|
+
try {
|
|
941
|
+
return JSON.parse(raw);
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Walk all notes attached to commits reachable from HEAD on the given ref
|
|
949
|
+
* and act based on their flags:
|
|
950
|
+
*
|
|
951
|
+
* - `promote_to_permanent: true` — write payload to the orphan layer under
|
|
952
|
+
* `promoted/<ref>/<sha>.json` and REMOVE the source note (the note has
|
|
953
|
+
* been promoted to permanent state and is no longer needed).
|
|
954
|
+
* - `archive_on_close: true` — copy payload to the orphan layer under
|
|
955
|
+
* `archive/<ref>/<sha>.json` and KEEP the source note (archive = copy).
|
|
956
|
+
* - Otherwise — leave the note alone (ephemeral, not worth promoting).
|
|
957
|
+
*
|
|
958
|
+
* Notes that fail to parse as JSON are counted as skipped.
|
|
959
|
+
*/
|
|
960
|
+
promoteNotes(ref) {
|
|
961
|
+
const result = { promoted: [], archived: [], skipped: 0 };
|
|
962
|
+
if (!this.isSafeRef(ref)) {
|
|
963
|
+
throw new Error(`[two-layer] promoteNotes: unsafe ref '${ref}'`);
|
|
964
|
+
}
|
|
965
|
+
const listing = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'list'], this.repoRoot);
|
|
966
|
+
if (!listing)
|
|
967
|
+
return result;
|
|
968
|
+
// git notes list output: "<noteSha> <commitSha>" per line.
|
|
969
|
+
const noteCommitPairs = [];
|
|
970
|
+
for (const line of listing.split('\n')) {
|
|
971
|
+
const parts = line.trim().split(/\s+/);
|
|
972
|
+
if (parts.length < 2)
|
|
973
|
+
continue;
|
|
974
|
+
const commitSha = parts[1];
|
|
975
|
+
if (this.isSafeCommitSha(commitSha))
|
|
976
|
+
noteCommitPairs.push({ commitSha });
|
|
977
|
+
}
|
|
978
|
+
if (noteCommitPairs.length === 0)
|
|
979
|
+
return result;
|
|
980
|
+
// Reachability filter: only commits reachable from HEAD.
|
|
981
|
+
const reachableRaw = gitExecMaybeMissing(['rev-list', 'HEAD'], this.repoRoot);
|
|
982
|
+
if (!reachableRaw)
|
|
983
|
+
return result;
|
|
984
|
+
const reachable = new Set(reachableRaw.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
985
|
+
const refKeySegment = this.sanitizeRefForKey(ref);
|
|
986
|
+
for (const { commitSha } of noteCommitPairs) {
|
|
987
|
+
if (!reachable.has(commitSha))
|
|
988
|
+
continue;
|
|
989
|
+
const raw = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'show', commitSha], this.repoRoot, false);
|
|
990
|
+
if (raw === null)
|
|
991
|
+
continue;
|
|
992
|
+
let payload;
|
|
993
|
+
try {
|
|
994
|
+
payload = JSON.parse(raw);
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
result.skipped++;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
const flags = payload;
|
|
1001
|
+
const shouldPromote = flags?.promote_to_permanent === true;
|
|
1002
|
+
const shouldArchive = flags?.archive_on_close === true;
|
|
1003
|
+
if (!shouldPromote && !shouldArchive) {
|
|
1004
|
+
result.skipped++;
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
// Stringify payload deterministically (2-space indent matches existing pattern).
|
|
1008
|
+
const body = JSON.stringify(payload, null, 2);
|
|
1009
|
+
if (shouldPromote) {
|
|
1010
|
+
const key = `promoted/${refKeySegment}/${commitSha}.json`;
|
|
1011
|
+
this.orphan.write(key, body);
|
|
1012
|
+
try {
|
|
1013
|
+
gitExecOrThrow(['notes', `--ref=${ref}`, 'remove', commitSha], this.repoRoot);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1017
|
+
console.warn(`[two-layer] promoteNotes: removed-source failed for ${commitSha} on ${ref}: ${msg}`);
|
|
1018
|
+
}
|
|
1019
|
+
result.promoted.push(key);
|
|
1020
|
+
}
|
|
1021
|
+
if (shouldArchive) {
|
|
1022
|
+
const key = `archive/${refKeySegment}/${commitSha}.json`;
|
|
1023
|
+
this.orphan.write(key, body);
|
|
1024
|
+
result.archived.push(key);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
/** True for refs that look like `squad/<name>` — alphanumerics, dash, underscore, slash. */
|
|
1030
|
+
isSafeRef(ref) {
|
|
1031
|
+
return /^[A-Za-z0-9_\-./]+$/.test(ref) && !ref.includes('..');
|
|
1032
|
+
}
|
|
1033
|
+
/** True for SHA-1 hex (40 chars) or SHA-256 hex (64 chars). */
|
|
1034
|
+
isSafeCommitSha(sha) {
|
|
1035
|
+
return /^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(sha);
|
|
1036
|
+
}
|
|
1037
|
+
/** Pass the ref through as path segments; normalizeKey will validate each. */
|
|
1038
|
+
sanitizeRefForKey(ref) {
|
|
1039
|
+
return ref.split('/').filter(Boolean).join('/');
|
|
555
1040
|
}
|
|
556
1041
|
}
|
|
557
1042
|
export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
|
|
@@ -566,7 +1051,10 @@ export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
|
|
|
566
1051
|
}
|
|
567
1052
|
}
|
|
568
1053
|
}
|
|
569
|
-
catch {
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1056
|
+
console.warn(`⚠️ Failed to read state backend config from ${path.join(squadDir, 'config.json')}: ${msg}`);
|
|
1057
|
+
}
|
|
570
1058
|
const explicitBackend = cliOverride !== undefined || configBackend !== undefined;
|
|
571
1059
|
const chosen = normalizeBackendType(cliOverride ?? configBackend ?? 'local');
|
|
572
1060
|
try {
|
|
@@ -574,23 +1062,62 @@ export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
|
|
|
574
1062
|
}
|
|
575
1063
|
catch (err) {
|
|
576
1064
|
const msg = err instanceof Error ? err.message : String(err);
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
console.warn(`Warning: State backend '${chosen}' failed: ${msg}. Falling back to 'local'.`);
|
|
1065
|
+
// Always fall back to local with a warning — a broken backend should not
|
|
1066
|
+
// prevent Squad from starting. Operators can fix config without losing work.
|
|
1067
|
+
console.warn(`Warning: State backend '${chosen}' failed${explicitBackend ? ' (explicit)' : ''}: ${msg}. Falling back to 'local'.`);
|
|
581
1068
|
return new WorktreeBackend(squadDir);
|
|
582
1069
|
}
|
|
583
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Read-only health check for a state backend.
|
|
1073
|
+
* Verifies the backend is accessible without mutating state.
|
|
1074
|
+
*
|
|
1075
|
+
* For {@link TwoLayerBackend}, both layers are probed independently — the
|
|
1076
|
+
* notes layer can fail (corrupt notes ref, missing commits) even when the
|
|
1077
|
+
* orphan layer is healthy, and we surface that explicitly.
|
|
1078
|
+
*/
|
|
1079
|
+
export function verifyStateBackend(backend) {
|
|
1080
|
+
try {
|
|
1081
|
+
backend.list('');
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1085
|
+
return { ok: false, error: `Backend '${backend.name}' verification failed: ${msg}` };
|
|
1086
|
+
}
|
|
1087
|
+
if (backend instanceof TwoLayerBackend) {
|
|
1088
|
+
try {
|
|
1089
|
+
backend.notes.list('');
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1093
|
+
return { ok: false, error: `Backend '${backend.name}' notes layer unhealthy: ${msg}` };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return { ok: true };
|
|
1097
|
+
}
|
|
584
1098
|
function isValidBackendType(value) {
|
|
585
1099
|
return ['local', 'worktree', 'external', 'git-notes', 'orphan', 'two-layer'].includes(value);
|
|
586
1100
|
}
|
|
587
1101
|
// Note: 'worktree' and 'git-notes' are accepted for backward compatibility but normalized away
|
|
1102
|
+
// One-shot flag: warn once per process so repeated resolveStateBackend() calls
|
|
1103
|
+
// (e.g. multiple agent startups in the same process) don't spam the console.
|
|
1104
|
+
let _warnedGitNotesMigration = false;
|
|
588
1105
|
/** Normalize legacy aliases to canonical backend type names. */
|
|
589
1106
|
function normalizeBackendType(type) {
|
|
590
1107
|
if (type === 'worktree')
|
|
591
1108
|
return 'local';
|
|
592
|
-
if (type === 'git-notes')
|
|
593
|
-
|
|
1109
|
+
if (type === 'git-notes') {
|
|
1110
|
+
if (!_warnedGitNotesMigration) {
|
|
1111
|
+
_warnedGitNotesMigration = true;
|
|
1112
|
+
console.warn("[squad] State backend 'git-notes' is deprecated and has been removed. " +
|
|
1113
|
+
"Your config is being silently migrated to 'two-layer', which creates a " +
|
|
1114
|
+
"'squad-state' orphan branch in your repository. " +
|
|
1115
|
+
"To suppress this warning, update .squad/config.json: " +
|
|
1116
|
+
"set \"stateBackend\": \"two-layer\". " +
|
|
1117
|
+
"See https://github.com/bradygaster/squad/blob/dev/docs/state-backends.md for upgrade instructions.");
|
|
1118
|
+
}
|
|
1119
|
+
return 'two-layer';
|
|
1120
|
+
}
|
|
594
1121
|
return type;
|
|
595
1122
|
}
|
|
596
1123
|
function createBackend(type, squadDir, repoRoot) {
|
|
@@ -602,11 +1129,18 @@ function createBackend(type, squadDir, repoRoot) {
|
|
|
602
1129
|
case 'two-layer':
|
|
603
1130
|
requireGitRepository(repoRoot);
|
|
604
1131
|
return new TwoLayerBackend(repoRoot);
|
|
605
|
-
case 'external':
|
|
1132
|
+
case 'external': {
|
|
1133
|
+
console.warn(`⚠️ State backend 'external' is a stub (PR #797). Using 'local' backend.`);
|
|
1134
|
+
return new WorktreeBackend(squadDir);
|
|
1135
|
+
}
|
|
606
1136
|
default: throw new Error(`Unknown state backend type: ${type}`);
|
|
607
1137
|
}
|
|
608
1138
|
}
|
|
609
1139
|
function requireGitRepository(repoRoot) {
|
|
610
1140
|
gitExecOrThrow(['rev-parse', '--git-dir'], repoRoot);
|
|
611
1141
|
}
|
|
1142
|
+
/** @internal Reset the one-shot git-notes migration warn flag. Only for use in tests. */
|
|
1143
|
+
export function _resetGitNotesMigrationWarnForTesting() {
|
|
1144
|
+
_warnedGitNotesMigration = false;
|
|
1145
|
+
}
|
|
612
1146
|
//# sourceMappingURL=state-backend.js.map
|