@ran-sh/dsh-crew 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- package/worker.cordis.yml +77 -0
package/src/workflow.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Unified job workflow: the shared state machine + structured outcome that
|
|
2
|
+
// every worker job goes through, whether it was started with dsh_run_worker
|
|
3
|
+
// (blocking) or dsh_spawn_worker (async). run and spawn differ only in whether
|
|
4
|
+
// the caller awaits the job; the business steps — created → running →
|
|
5
|
+
// verifying → (escalating | reviewing) → ready/completed/failed — are the
|
|
6
|
+
// same, driven by evidence rather than by which transport started the job.
|
|
7
|
+
//
|
|
8
|
+
// Everything in this module is a pure function (no DSH, hub or worker runtime,
|
|
9
|
+
// no I/O), so the workflow rules are unit-testable in isolation. The runtime
|
|
10
|
+
// layers stamp phase + outcome through these helpers so server.mjs stays a
|
|
11
|
+
// transport adapter.
|
|
12
|
+
|
|
13
|
+
import { evaluateAttempt } from './policy.mjs';
|
|
14
|
+
import { parseDeliveryReport } from './delivery.mjs';
|
|
15
|
+
|
|
16
|
+
export const JOB_PHASES = Object.freeze({
|
|
17
|
+
CREATED: 'created',
|
|
18
|
+
QUEUED: 'queued',
|
|
19
|
+
RUNNING: 'running',
|
|
20
|
+
VERIFYING: 'verifying',
|
|
21
|
+
ESCALATING: 'escalating',
|
|
22
|
+
REVIEWING: 'reviewing',
|
|
23
|
+
READY: 'ready',
|
|
24
|
+
COMPLETED: 'completed',
|
|
25
|
+
FAILED: 'failed',
|
|
26
|
+
CANCELLED: 'cancelled',
|
|
27
|
+
INTERRUPTED: 'interrupted',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const TERMINAL_PHASES = new Set([JOB_PHASES.COMPLETED, JOB_PHASES.FAILED, JOB_PHASES.CANCELLED, JOB_PHASES.INTERRUPTED]);
|
|
31
|
+
|
|
32
|
+
export function isTerminalPhase(phase) {
|
|
33
|
+
return TERMINAL_PHASES.has(phase);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Legal phase transitions (pure guard — the runtime never just assigns a phase).
|
|
37
|
+
const ALLOWED_TRANSITIONS = {
|
|
38
|
+
[JOB_PHASES.CREATED]: [JOB_PHASES.QUEUED, JOB_PHASES.RUNNING, JOB_PHASES.CANCELLED, JOB_PHASES.FAILED],
|
|
39
|
+
[JOB_PHASES.QUEUED]: [JOB_PHASES.RUNNING, JOB_PHASES.CANCELLED],
|
|
40
|
+
[JOB_PHASES.RUNNING]: [JOB_PHASES.VERIFYING, JOB_PHASES.REVIEWING, JOB_PHASES.CANCELLED, JOB_PHASES.FAILED],
|
|
41
|
+
[JOB_PHASES.VERIFYING]: [JOB_PHASES.ESCALATING, JOB_PHASES.REVIEWING, JOB_PHASES.READY, JOB_PHASES.FAILED, JOB_PHASES.CANCELLED],
|
|
42
|
+
[JOB_PHASES.ESCALATING]: [JOB_PHASES.RUNNING, JOB_PHASES.CANCELLED, JOB_PHASES.FAILED],
|
|
43
|
+
[JOB_PHASES.REVIEWING]: [JOB_PHASES.READY, JOB_PHASES.FAILED, JOB_PHASES.CANCELLED],
|
|
44
|
+
[JOB_PHASES.READY]: [JOB_PHASES.COMPLETED, JOB_PHASES.FAILED, JOB_PHASES.CANCELLED],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* May a workflow transition from one phase to another? Terminal phases never
|
|
49
|
+
* leave. A terminal destination is allowed only from a non-terminal phase;
|
|
50
|
+
* every non-terminal transition must be listed above.
|
|
51
|
+
*/
|
|
52
|
+
export function canTransition(from, to) {
|
|
53
|
+
const fromKey = from ?? JOB_PHASES.CREATED;
|
|
54
|
+
if (isTerminalPhase(fromKey)) return false;
|
|
55
|
+
if (isTerminalPhase(to)) return true;
|
|
56
|
+
const allowed = ALLOWED_TRANSITIONS[fromKey];
|
|
57
|
+
if (!allowed) return false;
|
|
58
|
+
return allowed.includes(to);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function splitSection(value) {
|
|
62
|
+
if (typeof value !== 'string' || value.trim() === '') return [];
|
|
63
|
+
return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseTests(section) {
|
|
67
|
+
return splitSection(section).map((line) => {
|
|
68
|
+
const m = line.match(/^(?:[-*+]\s+)?(PASS|FAIL|NOT RUN)\s+—\s+(.+?)\s+—\s+(.+)$/);
|
|
69
|
+
if (!m) return { line };
|
|
70
|
+
const [, status, command, summary] = m;
|
|
71
|
+
return { status, command: command.trim(), summary: summary.trim() };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify a worker run into a canonical task status. Completion of the
|
|
77
|
+
* process is not success: FAIL/not-run tests and missing delivery all downgrade
|
|
78
|
+
* the verdict. Returns 'success' | 'partial' | 'blocked' | 'failed'.
|
|
79
|
+
*/
|
|
80
|
+
export function classifyTaskStatus({ executionStatus = 'completed', testsStatus, deliveryComplete = true, deliveryMissing = [] } = {}) {
|
|
81
|
+
void deliveryMissing;
|
|
82
|
+
if (executionStatus !== 'completed') return 'failed';
|
|
83
|
+
if (testsStatus === 'FAIL') return 'partial';
|
|
84
|
+
if (!deliveryComplete) return 'blocked';
|
|
85
|
+
if (testsStatus === 'NOT RUN') return 'partial';
|
|
86
|
+
return 'success';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a worker's final message (+ delivery metadata) into the canonical
|
|
91
|
+
* structured outcome the workflow consumes. The Markdown Delivery Report stays
|
|
92
|
+
* the human-readable layer; this is the runtime's internal standard.
|
|
93
|
+
*/
|
|
94
|
+
export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopReason, deliveryMissing } = {}) {
|
|
95
|
+
const parsed = parseDeliveryReport(result);
|
|
96
|
+
const testsStatus = parsed.tests_status ?? deliveryMeta?.tests_status;
|
|
97
|
+
const tests = parseTests(parsed.sections.Tests);
|
|
98
|
+
const execStatus = executionStatus ?? (stopReason === 'completed' ? 'completed' : 'failed');
|
|
99
|
+
return {
|
|
100
|
+
execution_status: execStatus,
|
|
101
|
+
task_status: classifyTaskStatus({
|
|
102
|
+
executionStatus: execStatus,
|
|
103
|
+
testsStatus,
|
|
104
|
+
deliveryComplete: parsed.complete,
|
|
105
|
+
deliveryMissing: deliveryMissing ?? parsed.missing,
|
|
106
|
+
}),
|
|
107
|
+
confidence: null,
|
|
108
|
+
needs_escalation: false,
|
|
109
|
+
changes: splitSection(parsed.sections.Diff),
|
|
110
|
+
tests,
|
|
111
|
+
tests_status: testsStatus ?? null,
|
|
112
|
+
risks: splitSection(parsed.sections.Risks),
|
|
113
|
+
unverified: splitSection(parsed.sections.Unverified),
|
|
114
|
+
delivery: {
|
|
115
|
+
complete: parsed.complete,
|
|
116
|
+
missing: [...(parsed.missing ?? [])],
|
|
117
|
+
format: parsed.format,
|
|
118
|
+
sections: [...(parsed.present ?? [])],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decide what happens next after a worker attempt completes. Pure: consumes
|
|
125
|
+
* the canonical outcome + the role's model policy + the attempt number, and
|
|
126
|
+
* returns the workflow step (accept / escalate / review / fail) with a reason.
|
|
127
|
+
* This is the single rule for blocking AND async jobs — server.mjs only
|
|
128
|
+
* transports the result.
|
|
129
|
+
*/
|
|
130
|
+
export function decideNextStep({ outcome, policy, attempt = 0, reviewRequested = false, reviewerAuto = false } = {}) {
|
|
131
|
+
const o = outcome ?? {};
|
|
132
|
+
const evaluation = evaluateAttempt({
|
|
133
|
+
execution: o.execution_status,
|
|
134
|
+
taskStatus: o.task_status,
|
|
135
|
+
testsStatus: o.tests_status,
|
|
136
|
+
deliveryComplete: o.delivery?.complete,
|
|
137
|
+
workspaceEvidenceOK: o.workspace_evidence_ok !== false,
|
|
138
|
+
policy,
|
|
139
|
+
attempt,
|
|
140
|
+
});
|
|
141
|
+
if (evaluation.decision === 'escalate') {
|
|
142
|
+
return { step: 'escalate', phase: JOB_PHASES.ESCALATING, reason: evaluation.reason, evaluation };
|
|
143
|
+
}
|
|
144
|
+
if (reviewRequested && reviewerAuto && evaluation.decision === 'accept') {
|
|
145
|
+
return { step: 'review', phase: JOB_PHASES.REVIEWING, reason: 'review requested', evaluation };
|
|
146
|
+
}
|
|
147
|
+
if (evaluation.decision === 'accept') {
|
|
148
|
+
return { step: 'accept', phase: JOB_PHASES.READY, reason: 'verified', evaluation };
|
|
149
|
+
}
|
|
150
|
+
return { step: 'fail', phase: JOB_PHASES.FAILED, reason: evaluation.reason ?? 'no accept path', evaluation };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pure run/spawn parity check helper: given the same spec + outcome, blocking
|
|
155
|
+
* and async jobs must reach the same next step. Exported for tests so the
|
|
156
|
+
* "run and spawn share one workflow" guarantee is asserted directly.
|
|
157
|
+
*/
|
|
158
|
+
export function parityStep(spec) {
|
|
159
|
+
return decideNextStep(spec).step;
|
|
160
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Read-only workspace auditing for the auditable-delivery flow. Captures a
|
|
2
|
+
// before/after snapshot of a git working tree (status / stat / name-status)
|
|
3
|
+
// plus a bounded, redacted patch, so an orchestrator can review exactly what a
|
|
4
|
+
// worker changed before accepting the result.
|
|
5
|
+
//
|
|
6
|
+
// Discipline: strictly read-only. Only git porcelain reads are issued — never
|
|
7
|
+
// reset / stash / clean / checkout, and nothing here writes to disk. A
|
|
8
|
+
// non-git directory degrades to { kind: 'no-git' } instead of failing, and a
|
|
9
|
+
// pre-dirty workspace is flagged (dirtyBaseline) rather than hidden.
|
|
10
|
+
//
|
|
11
|
+
// The default `git` runner is replaceable with an injected runner in tests:
|
|
12
|
+
// it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { win32 } from 'node:path';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
export const DIFF_LIMIT = 64 * 1024;
|
|
22
|
+
export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
|
|
23
|
+
export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
|
|
24
|
+
export const GIT_TIMEOUT = 'GIT_TIMEOUT';
|
|
25
|
+
export const GIT_TIMEOUT_MS = 8000;
|
|
26
|
+
|
|
27
|
+
const SENSITIVE_SUFFIXES = ['.pem', '.key'];
|
|
28
|
+
const SENSITIVE_PREFIXES = ['credentials', 'secret'];
|
|
29
|
+
const ENV_BASENAME = '.env';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when a path looks like a credential/secret file whose patch content
|
|
33
|
+
* must be redacted: .env / .env.*, credentials*, secrets*, *.pem, *.key.
|
|
34
|
+
* The file's name-status entry stays visible — only the patch is hidden.
|
|
35
|
+
*/
|
|
36
|
+
export function isSensitivePath(relPath) {
|
|
37
|
+
const segments = String(relPath ?? '').replace(/\\/g, '/').split('/');
|
|
38
|
+
return segments.some((raw) => {
|
|
39
|
+
const seg = raw.toLowerCase();
|
|
40
|
+
if (seg === ENV_BASENAME || seg.startsWith(`${ENV_BASENAME}.`)) return true;
|
|
41
|
+
if (SENSITIVE_PREFIXES.some((p) => seg.startsWith(p))) return true;
|
|
42
|
+
return SENSITIVE_SUFFIXES.some((s) => seg.endsWith(s));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let resolvedGitExecutable = 'git';
|
|
47
|
+
|
|
48
|
+
export async function resolveWindowsGit({ exec = execFileAsync, env = process.env, exists = existsSync } = {}) {
|
|
49
|
+
try {
|
|
50
|
+
const located = await exec('where.exe', ['git'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
|
|
51
|
+
const first = String(located.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
52
|
+
if (first) return first;
|
|
53
|
+
} catch {}
|
|
54
|
+
// Always compose candidate paths with Windows semantics. This helper is
|
|
55
|
+
// unit-tested on non-Windows hosts too, and host-native path.join() would
|
|
56
|
+
// otherwise turn C:\\... inputs into mixed/invalid paths on POSIX.
|
|
57
|
+
const candidates = [
|
|
58
|
+
env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'cmd', 'git.exe'),
|
|
59
|
+
env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'bin', 'git.exe'),
|
|
60
|
+
env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe'),
|
|
61
|
+
].filter(Boolean);
|
|
62
|
+
return candidates.find((candidate) => exists(candidate));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function defaultRunner(args, { cwd }) {
|
|
66
|
+
let out;
|
|
67
|
+
try {
|
|
68
|
+
out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
|
|
71
|
+
if (!missing || process.platform !== 'win32' || resolvedGitExecutable !== 'git') throw error;
|
|
72
|
+
const fallback = await resolveWindowsGit();
|
|
73
|
+
if (!fallback) throw error;
|
|
74
|
+
resolvedGitExecutable = fallback;
|
|
75
|
+
out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
76
|
+
}
|
|
77
|
+
return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Run one git read; normalize failure into a no-git reason, never throws. */
|
|
81
|
+
async function runGit(runner, args, opts) {
|
|
82
|
+
try {
|
|
83
|
+
const r = await runner(args, opts);
|
|
84
|
+
const stderr = r.stderr ?? '';
|
|
85
|
+
if (/not a git repository/i.test(stderr)) {
|
|
86
|
+
return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const msg = err?.message ?? String(err);
|
|
91
|
+
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
|
+
if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
|
|
93
|
+
return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: msg };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function splitFirstTab(line) {
|
|
98
|
+
const i = line.indexOf('\t');
|
|
99
|
+
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse `git diff --name-status` + `git status --porcelain` into structured
|
|
104
|
+
* change lists used by the patch builder. Renames/copies fold onto their
|
|
105
|
+
* destination path (the diff shows them there); untracked `??` rows are listed
|
|
106
|
+
* separately because git does not diff them read-only.
|
|
107
|
+
*/
|
|
108
|
+
export function parseChanges(nameStatus, statusPorcelain) {
|
|
109
|
+
const modified = [];
|
|
110
|
+
const deleted = [];
|
|
111
|
+
const renamed = [];
|
|
112
|
+
for (const line of String(nameStatus ?? '').split('\n')) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (!trimmed) continue;
|
|
115
|
+
const [code, rest] = splitFirstTab(trimmed);
|
|
116
|
+
const paths = rest.split('\t').filter(Boolean);
|
|
117
|
+
if (paths.length === 0) continue;
|
|
118
|
+
const x = code[0] ?? '';
|
|
119
|
+
const y = code[1] ?? '';
|
|
120
|
+
if (y === 'D' || x === 'D') { deleted.push(paths[paths.length - 1]); continue; }
|
|
121
|
+
if (x === 'R' || x === 'C') { renamed.push(paths[0]); modified.push(paths[paths.length - 1]); continue; }
|
|
122
|
+
modified.push(paths[paths.length - 1]);
|
|
123
|
+
}
|
|
124
|
+
const untracked = String(statusPorcelain ?? '').split('\n')
|
|
125
|
+
.map((l) => l.trim())
|
|
126
|
+
.filter((l) => l.startsWith('??'))
|
|
127
|
+
.map((l) => l.slice(2).trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
return {
|
|
130
|
+
modified: [...new Set(modified)],
|
|
131
|
+
deleted: [...new Set(deleted)],
|
|
132
|
+
renamed: [...new Set(renamed)],
|
|
133
|
+
untracked: [...new Set(untracked)],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read-only pre-run snapshot of a workspace. Returns a git snapshot
|
|
139
|
+
* { kind:'git', status, nameStatus, stat, dirty, changes } or a degraded
|
|
140
|
+
* { kind:'no-git', reason, error } for non-repos / missing git. Never throws.
|
|
141
|
+
*/
|
|
142
|
+
export async function captureWorkspaceBaseline({ cwd, git } = {}) {
|
|
143
|
+
const runner = git ?? defaultRunner;
|
|
144
|
+
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
145
|
+
const [status, nameStatus, stat] = await Promise.all([
|
|
146
|
+
runGit(runner, ['status', '--porcelain'], { cwd }),
|
|
147
|
+
runGit(runner, ['diff', '--name-status'], { cwd }),
|
|
148
|
+
runGit(runner, ['diff', '--stat'], { cwd }),
|
|
149
|
+
]);
|
|
150
|
+
for (const r of [status, nameStatus, stat]) {
|
|
151
|
+
if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
|
|
152
|
+
}
|
|
153
|
+
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
154
|
+
return {
|
|
155
|
+
kind: 'git',
|
|
156
|
+
status: status.stdout,
|
|
157
|
+
nameStatus: nameStatus.stdout,
|
|
158
|
+
stat: stat.stdout,
|
|
159
|
+
dirty: changes.modified.length + changes.deleted.length + changes.renamed.length + changes.untracked.length > 0,
|
|
160
|
+
changes,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Build the bounded, redacted patch for the changed (tracked) paths. */
|
|
165
|
+
async function buildPatch(runner, { cwd, changes, limit }) {
|
|
166
|
+
const { modified, deleted, untracked } = changes;
|
|
167
|
+
const nonSensitive = [...new Set([...modified, ...deleted])].filter((p) => !isSensitivePath(p));
|
|
168
|
+
const sensitive = [...new Set([...modified, ...deleted])].filter((p) => isSensitivePath(p));
|
|
169
|
+
const redacted = [];
|
|
170
|
+
let patch = '';
|
|
171
|
+
if (nonSensitive.length > 0) {
|
|
172
|
+
const r = await runGit(runner, ['diff', '--binary', '--', ...nonSensitive], { cwd });
|
|
173
|
+
if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
|
|
174
|
+
patch += r.stdout;
|
|
175
|
+
}
|
|
176
|
+
for (const p of sensitive) {
|
|
177
|
+
redacted.push(p);
|
|
178
|
+
patch += `diff --git a/${p} b/${p}\n[REDACTED SENSITIVE FILE]\n`;
|
|
179
|
+
}
|
|
180
|
+
for (const p of untracked) {
|
|
181
|
+
if (isSensitivePath(p)) {
|
|
182
|
+
redacted.push(p);
|
|
183
|
+
patch += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
|
|
184
|
+
} else {
|
|
185
|
+
patch += `[UNTRACKED FILE: ${p} (content not diffed read-only)]\n`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let truncated = false;
|
|
189
|
+
if (Buffer.byteLength(patch, 'utf8') > limit) {
|
|
190
|
+
patch = Buffer.from(patch, 'utf8').subarray(0, limit).toString('utf8');
|
|
191
|
+
truncated = true;
|
|
192
|
+
}
|
|
193
|
+
return { failed: false, patch, truncated, redacted };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read-only post-run snapshot: the after-state (status / stat / name-status)
|
|
198
|
+
* plus the bounded, redacted patch of everything the worker changed.
|
|
199
|
+
* `dirtyBaseline` records whether the workspace was already dirty before the
|
|
200
|
+
* worker started (the diff may then include pre-existing changes). Never
|
|
201
|
+
* throws; degrades to { kind:'no-git' } like captureWorkspaceBaseline.
|
|
202
|
+
*/
|
|
203
|
+
export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
|
|
204
|
+
const runner = git ?? defaultRunner;
|
|
205
|
+
if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
|
|
206
|
+
if (!baseline || baseline.kind !== 'git') {
|
|
207
|
+
return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
|
|
208
|
+
}
|
|
209
|
+
const [status, nameStatus, stat] = await Promise.all([
|
|
210
|
+
runGit(runner, ['status', '--porcelain'], { cwd }),
|
|
211
|
+
runGit(runner, ['diff', '--name-status'], { cwd }),
|
|
212
|
+
runGit(runner, ['diff', '--stat'], { cwd }),
|
|
213
|
+
]);
|
|
214
|
+
for (const r of [status, nameStatus, stat]) {
|
|
215
|
+
if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
|
|
216
|
+
}
|
|
217
|
+
const changes = parseChanges(nameStatus.stdout, status.stdout);
|
|
218
|
+
const patch = await buildPatch(runner, { cwd, changes, limit });
|
|
219
|
+
if (patch.failed) return { kind: 'no-git', reason: patch.reason, error: patch.error };
|
|
220
|
+
return {
|
|
221
|
+
kind: 'git',
|
|
222
|
+
status: status.stdout,
|
|
223
|
+
nameStatus: nameStatus.stdout,
|
|
224
|
+
stat: stat.stdout,
|
|
225
|
+
patch: patch.patch,
|
|
226
|
+
truncated: patch.truncated,
|
|
227
|
+
redacted: patch.redacted,
|
|
228
|
+
dirtyBaseline: baseline.dirty === true,
|
|
229
|
+
changes,
|
|
230
|
+
};
|
|
231
|
+
}
|