@ran-sh/dsh-crew 2.0.2 → 2.0.4
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/.claude-plugin/plugin.json +1 -1
- package/docs/job-contracts.md +12 -7
- package/package.json +1 -1
- package/src/hub/index.mjs +25 -16
- package/src/mcp-runtime.mjs +4 -2
- package/src/runtime-identity.mjs +1 -1
- package/src/workflow-runtime.mjs +16 -6
- package/src/workspace-audit.mjs +123 -9
package/docs/job-contracts.md
CHANGED
|
@@ -85,13 +85,18 @@ Per-job precedence is request `constraints` > Profile > session defaults.
|
|
|
85
85
|
`auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
|
|
86
86
|
`READ_ONLY`, or `UNAVAILABLE` before dispatch.
|
|
87
87
|
|
|
88
|
-
`constraints.allow_no_changes: true` is
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
`constraints.allow_no_changes: true` is for tasks explicitly expected to leave
|
|
89
|
+
no net workspace changes, including read-only search/inspection and bounded
|
|
90
|
+
temporary smoke work. It does not blindly convert a partial Delivery Report
|
|
91
|
+
into success: Crew requires a complete Delivery Report, at least one passing
|
|
92
|
+
evidence check, no failed check, and machine-verifiable zero net workspace
|
|
93
|
+
changes. For Hub jobs, that evidence comes from a clean Git baseline, or from a
|
|
94
|
+
non-Git workspace whose initial tree contains only directories; the latter audit
|
|
95
|
+
compares paths and entry types without opening file contents. Non-Git trees that
|
|
96
|
+
already contain files, links, or special entries remain unverifiable and fail
|
|
97
|
+
closed. If the Worker reports no changes but the workspace contains edits—or
|
|
98
|
+
reports changes while the workspace is empty—the workflow fails closed with
|
|
99
|
+
workspace-evidence mismatch.
|
|
95
100
|
|
|
96
101
|
The CLI is a JSON projection of the same local HTTP surface:
|
|
97
102
|
|
package/package.json
CHANGED
package/src/hub/index.mjs
CHANGED
|
@@ -502,10 +502,9 @@ export function hubCanonicalEvents(job = {}) {
|
|
|
502
502
|
|
|
503
503
|
export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
|
|
504
504
|
void isolation;
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
|
|
505
|
+
const hasChanges = workspaceHasChanges(workspaceDiff);
|
|
506
|
+
const evidenceAvailable = (workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true)
|
|
507
|
+
|| (workspaceDiff?.kind === 'filesystem-empty' && typeof workspaceDiff.unchanged === 'boolean');
|
|
509
508
|
return applyWorkspaceEvidence(outcome, {
|
|
510
509
|
evidenceAvailable,
|
|
511
510
|
hasChanges,
|
|
@@ -521,6 +520,19 @@ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChang
|
|
|
521
520
|
});
|
|
522
521
|
}
|
|
523
522
|
|
|
523
|
+
function workspaceHasChanges(workspaceDiff) {
|
|
524
|
+
if (workspaceDiff?.kind === 'filesystem-empty') return workspaceDiff.unchanged !== true;
|
|
525
|
+
const changes = workspaceDiff?.changes ?? {};
|
|
526
|
+
return ['modified', 'deleted', 'renamed', 'untracked']
|
|
527
|
+
.some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function invalidateReviewForWorkspaceMutation({ role, review, workspaceDiff } = {}) {
|
|
531
|
+
if (role !== 'reviewer' || !review || !['git', 'filesystem-empty'].includes(workspaceDiff?.kind)) return review;
|
|
532
|
+
if (!workspaceHasChanges(workspaceDiff)) return review;
|
|
533
|
+
return { ...review, mutated_candidate: true, invalidated: true, verdict: 'request_changes' };
|
|
534
|
+
}
|
|
535
|
+
|
|
524
536
|
/**
|
|
525
537
|
* Build the explicit provider callability probe used by the 3210 lifecycle
|
|
526
538
|
* route. It performs one token-bounded Harness stream through the same llm
|
|
@@ -602,7 +614,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
602
614
|
task_status: job.outcome?.task_status ?? null,
|
|
603
615
|
workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
|
|
604
616
|
review_verdict: job.review?.verdict ?? null,
|
|
605
|
-
workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind
|
|
617
|
+
workspace_diff_available: !!job.workspaceDiff && ['git', 'filesystem-empty'].includes(job.workspaceDiff.kind),
|
|
606
618
|
workspace_retained: job.workspace_retained === true,
|
|
607
619
|
cleanup_warning: job.cleanup_warning ?? null,
|
|
608
620
|
profile_id: job.profile_id ?? null,
|
|
@@ -935,7 +947,8 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
935
947
|
// Read-only pre-run snapshot (async, never blocks dispatch): the audit
|
|
936
948
|
// only needs the before-state by the time the worker finishes. Non-repos
|
|
937
949
|
// degrade to { kind:'no-git' } instead of failing the job.
|
|
938
|
-
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd
|
|
950
|
+
job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd, allowEmptyNonGitDirectory: true })
|
|
951
|
+
.catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
939
952
|
try {
|
|
940
953
|
this.assertProviderNotMutating(selection.provider, providerMutationEpoch);
|
|
941
954
|
this.acquireProviderLease(selection.provider);
|
|
@@ -1120,7 +1133,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
1120
1133
|
});
|
|
1121
1134
|
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
1122
1135
|
// Read-only after-snapshot of the workspace: bounded, redacted patch.
|
|
1123
|
-
job.workspaceDiff = job.baseline.kind
|
|
1136
|
+
job.workspaceDiff = ['git', 'filesystem-empty'].includes(job.baseline.kind)
|
|
1124
1137
|
? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
1125
1138
|
: job.baseline;
|
|
1126
1139
|
job.outcome = applyHubWorkspaceEvidence({
|
|
@@ -1139,15 +1152,11 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
1139
1152
|
error: { code: job.error_code, message: job.error },
|
|
1140
1153
|
});
|
|
1141
1154
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
job.review.invalidated = true;
|
|
1148
|
-
job.review.verdict = 'request_changes';
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1155
|
+
job.review = invalidateReviewForWorkspaceMutation({
|
|
1156
|
+
role: job.role,
|
|
1157
|
+
review: job.review,
|
|
1158
|
+
workspaceDiff: job.workspaceDiff,
|
|
1159
|
+
});
|
|
1151
1160
|
if (job.isolatedWorkspace) {
|
|
1152
1161
|
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
1153
1162
|
job.workspace_retained = cleanup.ok !== true;
|
package/src/mcp-runtime.mjs
CHANGED
|
@@ -283,8 +283,10 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
283
283
|
const config = getConfig();
|
|
284
284
|
const isolation = config.execution?.isolation ?? 'worktree';
|
|
285
285
|
// Explicit shared mode uses the requested workspace. Readonly profiles,
|
|
286
|
-
// including the default Reviewer,
|
|
287
|
-
// accidental edit
|
|
286
|
+
// including the default Reviewer, take the disposable worktree below, so an
|
|
287
|
+
// accidental edit cannot reach the primary tree at all. A reviewer that was
|
|
288
|
+
// explicitly given a shared workspace still gets fingerprinted around its
|
|
289
|
+
// attempt, so an edit there is detected even though it is not prevented.
|
|
288
290
|
if (job.requested_isolation === 'shared' || isolation === 'shared') {
|
|
289
291
|
return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
|
|
290
292
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '2.0.
|
|
39
|
+
export const RUNTIME_VERSION = '2.0.4';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -289,7 +289,10 @@ export function createWorkflowRuntime(adapters, {
|
|
|
289
289
|
|
|
290
290
|
async function releaseWorkspace(job) {
|
|
291
291
|
if (job.retain_workspace) {
|
|
292
|
-
|
|
292
|
+
// Retention means an isolated workspace was deliberately kept for
|
|
293
|
+
// recovery. A shared workspace is the caller's own and was never ours to
|
|
294
|
+
// keep, so an evidence failure there retains nothing.
|
|
295
|
+
if (job.workspaceHandle) job.workspace_retained = true;
|
|
293
296
|
return;
|
|
294
297
|
}
|
|
295
298
|
if (!job.workspaceHandle) return;
|
|
@@ -342,7 +345,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
342
345
|
|
|
343
346
|
if (isReviewJob) {
|
|
344
347
|
transition(job, JOB_PHASES.REVIEWING, 'explicit reviewer');
|
|
345
|
-
const before = alloc?.ok
|
|
348
|
+
const before = alloc?.ok ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
|
|
346
349
|
// Explicit reviewers inspect the caller's stated task/workspace
|
|
347
350
|
// directly. The bounded worker-candidate capsule belongs only to the
|
|
348
351
|
// automatic post-worker review path; constructing it with no worker
|
|
@@ -553,8 +556,13 @@ export function createWorkflowRuntime(adapters, {
|
|
|
553
556
|
}
|
|
554
557
|
|
|
555
558
|
async function runReviewerAttempt(job, task, config, beforeCandidate, cwd = job.execution_cwd, baseRevision = job.base_revision) {
|
|
559
|
+
// Reviewing is the one role whose entire contract is "changed nothing", so
|
|
560
|
+
// its evidence is not exempted by isolation. In a worktree the pre-review
|
|
561
|
+
// candidate comes from the allocator or the worker and its absence is a real
|
|
562
|
+
// failure; a shared workspace has no such candidate, so fingerprint the tree
|
|
563
|
+
// the reviewer is about to touch — otherwise the reviewer is the one actor
|
|
564
|
+
// whose edits nothing observes.
|
|
556
565
|
const requireFingerprint = (candidate) => {
|
|
557
|
-
if (job.isolation !== 'worktree') return;
|
|
558
566
|
if (candidate?.ok === true && typeof candidate.fingerprint === 'string' && candidate.fingerprint.trim()) return;
|
|
559
567
|
job.candidate_capture_failed = true;
|
|
560
568
|
job.retain_workspace = true;
|
|
@@ -562,7 +570,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
562
570
|
code: 'REVIEW_EVIDENCE_UNAVAILABLE',
|
|
563
571
|
});
|
|
564
572
|
};
|
|
565
|
-
|
|
573
|
+
const before = beforeCandidate
|
|
574
|
+
?? (job.isolation === 'worktree' ? null : await safeCapture(adapters, cwd, baseRevision));
|
|
575
|
+
requireFingerprint(before);
|
|
566
576
|
const policy = resolveModelPolicy(config, 'reviewer', { attempt: 0 });
|
|
567
577
|
const attemptId = attemptIdFor(adapters, job.id, 'review');
|
|
568
578
|
job.current_attempt_id = attemptId;
|
|
@@ -594,9 +604,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
594
604
|
fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
|
|
595
605
|
}, 0);
|
|
596
606
|
if (ar.status === 'failed' && attemptView.error_code) job.error_code = attemptView.error_code;
|
|
597
|
-
const afterCandidate =
|
|
607
|
+
const afterCandidate = await safeCapture(adapters, cwd, baseRevision);
|
|
598
608
|
requireFingerprint(afterCandidate);
|
|
599
|
-
const review = normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
|
|
609
|
+
const review = normalizeReview({ attemptResult: ar, beforeCandidate: before, afterCandidate });
|
|
600
610
|
recordCanonical(job, 'review.completed', {
|
|
601
611
|
attempt_id: attemptView.id,
|
|
602
612
|
status: review.status,
|
package/src/workspace-audit.mjs
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Discipline: strictly read-only. Only git porcelain reads are issued — never
|
|
7
7
|
// reset / stash / clean / checkout, and nothing here writes to disk. A
|
|
8
|
-
// non-git directory degrades to { kind: 'no-git' }
|
|
9
|
-
//
|
|
8
|
+
// non-git directory degrades to { kind: 'no-git' } by default, and a pre-dirty
|
|
9
|
+
// workspace is flagged (dirtyBaseline) rather than hidden. Hub callers may
|
|
10
|
+
// opt into a bounded directory-only snapshot for initially empty non-Git trees.
|
|
10
11
|
//
|
|
11
12
|
// The default `git` runner is replaceable with an injected runner in tests:
|
|
12
13
|
// it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
|
|
13
14
|
|
|
14
15
|
import { execFile } from 'node:child_process';
|
|
15
16
|
import { existsSync } from 'node:fs';
|
|
16
|
-
import {
|
|
17
|
+
import { lstat, opendir, realpath } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, relative, resolve, sep, win32 } from 'node:path';
|
|
17
19
|
import { promisify } from 'node:util';
|
|
18
20
|
|
|
19
21
|
const execFileAsync = promisify(execFile);
|
|
@@ -22,7 +24,10 @@ export const DIFF_LIMIT = 64 * 1024;
|
|
|
22
24
|
export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
|
|
23
25
|
export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
|
|
24
26
|
export const GIT_TIMEOUT = 'GIT_TIMEOUT';
|
|
27
|
+
export const GIT_ERROR = 'GIT_ERROR';
|
|
25
28
|
export const GIT_TIMEOUT_MS = 8000;
|
|
29
|
+
const EMPTY_TREE_ENTRY_LIMIT = 20_000;
|
|
30
|
+
const EMPTY_TREE_DEPTH_LIMIT = 64;
|
|
26
31
|
|
|
27
32
|
const SENSITIVE_SUFFIXES = ['.pem', '.key'];
|
|
28
33
|
const SENSITIVE_PREFIXES = ['credentials', 'secret'];
|
|
@@ -85,15 +90,103 @@ async function runGit(runner, args, opts) {
|
|
|
85
90
|
if (/not a git repository/i.test(stderr)) {
|
|
86
91
|
return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
|
|
87
92
|
}
|
|
93
|
+
if (Number.isInteger(r.code) && r.code !== 0) {
|
|
94
|
+
return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
|
|
95
|
+
}
|
|
88
96
|
return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
|
|
89
97
|
} catch (err) {
|
|
90
98
|
const msg = err?.message ?? String(err);
|
|
99
|
+
const diagnostic = `${err?.stderr ?? ''}\n${msg}`;
|
|
100
|
+
if (/not a git repository/i.test(diagnostic)) return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: 'not a git repository' };
|
|
91
101
|
if (err?.code === 'ETIMEDOUT' || err?.killed === true || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: 'git audit timed out' };
|
|
92
102
|
if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
|
|
93
|
-
return { ok: false, reason:
|
|
103
|
+
return { ok: false, reason: GIT_ERROR, error: 'git audit failed' };
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
function samePath(left, right) {
|
|
108
|
+
const normalizedLeft = resolve(left).replace(/[\\/]+$/, '');
|
|
109
|
+
const normalizedRight = resolve(right).replace(/[\\/]+$/, '');
|
|
110
|
+
return process.platform === 'win32'
|
|
111
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
112
|
+
: normalizedLeft === normalizedRight;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pathIsWithin(root, candidate) {
|
|
116
|
+
const rel = relative(root, candidate);
|
|
117
|
+
const comparable = process.platform === 'win32' ? rel.toLowerCase() : rel;
|
|
118
|
+
return comparable === '' || (comparable !== '..' && !comparable.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Capture names and entry kinds only; never open file contents. This fallback
|
|
123
|
+
* is eligible only when the pre-run tree has no files or links, so it can prove
|
|
124
|
+
* that a temporary file/directory was removed without reading existing data.
|
|
125
|
+
*/
|
|
126
|
+
async function captureDirectoryEntries(cwd) {
|
|
127
|
+
let root;
|
|
128
|
+
try {
|
|
129
|
+
const requested = resolve(cwd);
|
|
130
|
+
const requestedStat = await lstat(requested);
|
|
131
|
+
if (!requestedStat.isDirectory() || requestedStat.isSymbolicLink()) {
|
|
132
|
+
return { ok: false, reason: 'WORKSPACE_ROOT_UNSAFE' };
|
|
133
|
+
}
|
|
134
|
+
root = await realpath(requested);
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pending = [{ absolute: root, depth: 0 }];
|
|
140
|
+
const entries = [];
|
|
141
|
+
let regularFileCount = 0;
|
|
142
|
+
let linkOrSpecialCount = 0;
|
|
143
|
+
try {
|
|
144
|
+
while (pending.length > 0) {
|
|
145
|
+
const current = pending.pop();
|
|
146
|
+
if (current.depth > EMPTY_TREE_DEPTH_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
|
|
147
|
+
const beforeStat = await lstat(current.absolute);
|
|
148
|
+
if (!beforeStat.isDirectory() || beforeStat.isSymbolicLink()) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
149
|
+
const canonical = await realpath(current.absolute);
|
|
150
|
+
if (!pathIsWithin(root, canonical)) return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
151
|
+
const children = await opendir(canonical);
|
|
152
|
+
for await (const child of children) {
|
|
153
|
+
if (entries.length >= EMPTY_TREE_ENTRY_LIMIT) return { ok: false, reason: 'WORKSPACE_SCAN_LIMIT' };
|
|
154
|
+
const absolute = resolve(canonical, child.name);
|
|
155
|
+
const entryPath = relative(root, absolute).split(sep).join('/');
|
|
156
|
+
const stat = await lstat(absolute);
|
|
157
|
+
let type;
|
|
158
|
+
if (stat.isSymbolicLink()) {
|
|
159
|
+
type = 'link';
|
|
160
|
+
linkOrSpecialCount += 1;
|
|
161
|
+
} else if (stat.isDirectory()) {
|
|
162
|
+
type = 'directory';
|
|
163
|
+
pending.push({ absolute, depth: current.depth + 1 });
|
|
164
|
+
} else if (stat.isFile()) {
|
|
165
|
+
type = 'file';
|
|
166
|
+
regularFileCount += 1;
|
|
167
|
+
} else {
|
|
168
|
+
type = 'special';
|
|
169
|
+
linkOrSpecialCount += 1;
|
|
170
|
+
}
|
|
171
|
+
entries.push({ path: entryPath, type });
|
|
172
|
+
}
|
|
173
|
+
const afterStat = await lstat(current.absolute);
|
|
174
|
+
const afterCanonical = await realpath(current.absolute);
|
|
175
|
+
if (!afterStat.isDirectory() || afterStat.isSymbolicLink()
|
|
176
|
+
|| !samePath(canonical, afterCanonical)
|
|
177
|
+
|| beforeStat.mtimeMs !== afterStat.mtimeMs
|
|
178
|
+
|| beforeStat.ctimeMs !== afterStat.ctimeMs) {
|
|
179
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
return { ok: false, reason: 'WORKSPACE_SCAN_UNAVAILABLE' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
187
|
+
return { ok: true, entries, regularFileCount, linkOrSpecialCount };
|
|
188
|
+
}
|
|
189
|
+
|
|
97
190
|
function splitFirstTab(line) {
|
|
98
191
|
const i = line.indexOf('\t');
|
|
99
192
|
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
@@ -136,10 +229,11 @@ export function parseChanges(nameStatus, statusPorcelain) {
|
|
|
136
229
|
|
|
137
230
|
/**
|
|
138
231
|
* Read-only pre-run snapshot of a workspace. Returns a git snapshot
|
|
139
|
-
* { kind:'git', status, nameStatus, stat, dirty, changes }
|
|
140
|
-
* { kind:'
|
|
232
|
+
* { kind:'git', status, nameStatus, stat, dirty, changes }, an opted-in
|
|
233
|
+
* { kind:'filesystem-empty', entries } for an empty non-Git tree, or a degraded
|
|
234
|
+
* { kind:'no-git', reason, error } for other non-repos / missing Git. Never throws.
|
|
141
235
|
*/
|
|
142
|
-
export async function captureWorkspaceBaseline({ cwd, git } = {}) {
|
|
236
|
+
export async function captureWorkspaceBaseline({ cwd, git, allowEmptyNonGitDirectory = false } = {}) {
|
|
143
237
|
const runner = git ?? defaultRunner;
|
|
144
238
|
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
145
239
|
const [status, nameStatus, stat] = await Promise.all([
|
|
@@ -147,8 +241,19 @@ export async function captureWorkspaceBaseline({ cwd, git } = {}) {
|
|
|
147
241
|
runGit(runner, ['diff', '--name-status'], { cwd }),
|
|
148
242
|
runGit(runner, ['diff', '--stat'], { cwd }),
|
|
149
243
|
]);
|
|
150
|
-
|
|
151
|
-
|
|
244
|
+
const results = [status, nameStatus, stat];
|
|
245
|
+
const failures = results.filter((result) => !result.ok);
|
|
246
|
+
if (failures.length > 0) {
|
|
247
|
+
const allExplicitlyNonGit = failures.length === results.length
|
|
248
|
+
&& failures.every((result) => result.reason === NOT_A_GIT_REPOSITORY);
|
|
249
|
+
if (allExplicitlyNonGit && allowEmptyNonGitDirectory === true) {
|
|
250
|
+
const tree = await captureDirectoryEntries(cwd);
|
|
251
|
+
if (tree.ok && tree.regularFileCount === 0 && tree.linkOrSpecialCount === 0) {
|
|
252
|
+
return { kind: 'filesystem-empty', entries: tree.entries };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const failed = failures.find((result) => result.reason !== NOT_A_GIT_REPOSITORY) ?? failures[0];
|
|
256
|
+
return { kind: 'no-git', reason: failed.reason, error: failed.error };
|
|
152
257
|
}
|
|
153
258
|
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
154
259
|
return {
|
|
@@ -203,6 +308,15 @@ async function buildPatch(runner, { cwd, changes, limit }) {
|
|
|
203
308
|
export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
|
|
204
309
|
const runner = git ?? defaultRunner;
|
|
205
310
|
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
311
|
+
if (baseline?.kind === 'filesystem-empty') {
|
|
312
|
+
const tree = await captureDirectoryEntries(cwd);
|
|
313
|
+
if (!tree.ok) return { kind: 'no-git', reason: tree.reason, error: 'filesystem audit unavailable' };
|
|
314
|
+
return {
|
|
315
|
+
kind: 'filesystem-empty',
|
|
316
|
+
dirtyBaseline: false,
|
|
317
|
+
unchanged: JSON.stringify(tree.entries) === JSON.stringify(baseline.entries),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
206
320
|
if (!baseline || baseline.kind !== 'git') {
|
|
207
321
|
return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
|
|
208
322
|
}
|