@khorsheed/dsh-ankh-guard 0.1.1 → 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/CHANGELOG.md +33 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +344 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +746 -40
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/restart-context-DlaDZWKq.js +1250 -0
- package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +348 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +192 -41
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +117 -6
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable evidence for a pure, same-launch restart.
|
|
3
|
+
*
|
|
4
|
+
* A green credential is deliberately short lived: it authorizes one stop
|
|
5
|
+
* close to the observed build/test run. Once that stop has completed
|
|
6
|
+
* readiness and canary, however, the exact deployed inputs are known-good.
|
|
7
|
+
* This module fingerprints those inputs so a later pure restart can reuse the
|
|
8
|
+
* expensive proof without turning "same git HEAD" into a weak proxy for
|
|
9
|
+
* "same deployment".
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, } from 'node:fs';
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
14
|
+
import { currentHead, isWorkingTreeClean } from "./git.js";
|
|
15
|
+
import { commandSha256 } from "./launch-spec.js";
|
|
16
|
+
import { loadState, setProvenDeployment, verifyCredential, } from "./state.js";
|
|
17
|
+
const PROFILE_FILES = [
|
|
18
|
+
'cordis.patch.yml', 'cordis.yml', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
|
|
19
|
+
];
|
|
20
|
+
const HOST_RUNTIME_FILES = [
|
|
21
|
+
'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
|
|
22
|
+
'node_modules/.modules.yaml', 'node_modules/.pnpm-workspace-state-v1.json',
|
|
23
|
+
];
|
|
24
|
+
const SKIPPED_PACKAGE_DIRS = new Set(['.git', 'coverage', 'node_modules']);
|
|
25
|
+
function hashChunk(hash, kind, logicalPath, value = '') {
|
|
26
|
+
hash.update(kind);
|
|
27
|
+
hash.update('\0');
|
|
28
|
+
hash.update(logicalPath);
|
|
29
|
+
hash.update('\0');
|
|
30
|
+
hash.update(value);
|
|
31
|
+
hash.update('\0');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Hash a runtime tree, following links so a linked package cannot drift
|
|
35
|
+
* outside the fingerprint. Real-directory de-duplication permits legal link
|
|
36
|
+
* cycles without recursion loops. Special/dangling/unreadable nodes fail
|
|
37
|
+
* closed.
|
|
38
|
+
*/
|
|
39
|
+
function hashRuntimePath(hash, absolutePath, logicalPath, seenDirectories, packageRoot = false) {
|
|
40
|
+
const info = lstatSync(absolutePath);
|
|
41
|
+
if (info.isSymbolicLink()) {
|
|
42
|
+
const targetText = readlinkSync(absolutePath);
|
|
43
|
+
hashChunk(hash, 'link', logicalPath, targetText);
|
|
44
|
+
let target;
|
|
45
|
+
try {
|
|
46
|
+
target = realpathSync(absolutePath);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`deployment fingerprint refused dangling link ${logicalPath}: ${String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
hashRuntimePath(hash, target, `${logicalPath}/@target`, seenDirectories, packageRoot);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (info.isFile()) {
|
|
55
|
+
hashChunk(hash, 'file', logicalPath, readFileSync(absolutePath));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!info.isDirectory()) {
|
|
59
|
+
throw new Error(`deployment fingerprint refused special file ${logicalPath}`);
|
|
60
|
+
}
|
|
61
|
+
const realDirectory = realpathSync(absolutePath);
|
|
62
|
+
if (seenDirectories.has(realDirectory)) {
|
|
63
|
+
hashChunk(hash, 'cycle', logicalPath);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
seenDirectories.add(realDirectory);
|
|
67
|
+
hashChunk(hash, 'dir', logicalPath);
|
|
68
|
+
for (const name of readdirSync(absolutePath).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
|
|
69
|
+
if (packageRoot && SKIPPED_PACKAGE_DIRS.has(name)) {
|
|
70
|
+
hashChunk(hash, 'excluded-dir', `${logicalPath}/${name}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
hashRuntimePath(hash, join(absolutePath, name), `${logicalPath}/${name}`, seenDirectories, packageRoot);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function hashOptionalPath(hash, root, relativePath) {
|
|
77
|
+
const absolutePath = join(root, relativePath);
|
|
78
|
+
if (!existsSync(absolutePath)) {
|
|
79
|
+
hashChunk(hash, 'absent', relativePath);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
hashRuntimePath(hash, absolutePath, relativePath, new Set());
|
|
83
|
+
}
|
|
84
|
+
function dependencyInstallPath(profileDir, dependency) {
|
|
85
|
+
return join(profileDir, 'node_modules', ...dependency.split('/'));
|
|
86
|
+
}
|
|
87
|
+
function fileDependencyPath(profileDir, specifier) {
|
|
88
|
+
if (!specifier.startsWith('file:'))
|
|
89
|
+
return undefined;
|
|
90
|
+
const pathname = specifier.slice('file:'.length);
|
|
91
|
+
return isAbsolute(pathname) ? pathname : resolve(profileDir, pathname);
|
|
92
|
+
}
|
|
93
|
+
/** Hash profile inputs, installed direct packages, and file: source archives. */
|
|
94
|
+
function profileRuntimeSha256(spec) {
|
|
95
|
+
const profileDir = join(spec.home, 'profiles', spec.profile);
|
|
96
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
97
|
+
if (!existsSync(manifestPath))
|
|
98
|
+
throw new Error(`deployment fingerprint requires ${manifestPath}`);
|
|
99
|
+
let manifest;
|
|
100
|
+
try {
|
|
101
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
throw new Error(`deployment fingerprint could not parse profile package.json: ${String(error)}`);
|
|
105
|
+
}
|
|
106
|
+
const hash = createHash('sha256');
|
|
107
|
+
for (const file of PROFILE_FILES)
|
|
108
|
+
hashOptionalPath(hash, profileDir, file);
|
|
109
|
+
const dependencies = Object.entries(manifest.dependencies ?? {})
|
|
110
|
+
.sort(([left], [right]) => Buffer.from(left).compare(Buffer.from(right)));
|
|
111
|
+
for (const [name, source] of dependencies) {
|
|
112
|
+
const installed = dependencyInstallPath(profileDir, name);
|
|
113
|
+
if (!existsSync(installed))
|
|
114
|
+
throw new Error(`deployment fingerprint requires installed profile dependency ${name}`);
|
|
115
|
+
hashRuntimePath(hash, installed, `installed/${name}`, new Set(), true);
|
|
116
|
+
const archive = fileDependencyPath(profileDir, source);
|
|
117
|
+
if (archive !== undefined) {
|
|
118
|
+
if (!existsSync(archive))
|
|
119
|
+
throw new Error(`deployment fingerprint requires file dependency archive for ${name}`);
|
|
120
|
+
hashRuntimePath(hash, archive, `source-archive/${name}`, new Set());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return hash.digest('hex');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Hash the host install metadata and any explicitly bound built execution
|
|
127
|
+
* surface. Source surfaces are content-bound by the clean harness git tree.
|
|
128
|
+
*/
|
|
129
|
+
function hostRuntimeSha256(spec) {
|
|
130
|
+
const hash = createHash('sha256');
|
|
131
|
+
for (const file of HOST_RUNTIME_FILES)
|
|
132
|
+
hashOptionalPath(hash, spec.harnessRoot, file);
|
|
133
|
+
if (spec.preflight !== undefined) {
|
|
134
|
+
hashRuntimePath(hash, spec.preflight.runnerPath, 'preflight/runner', new Set());
|
|
135
|
+
hashRuntimePath(hash, spec.preflight.installAnchor, 'preflight/install-anchor', new Set());
|
|
136
|
+
if (spec.preflight.surface === 'built') {
|
|
137
|
+
hashRuntimePath(hash, dirname(spec.preflight.installAnchor), 'preflight/built-host-package', new Set(), true);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return hash.digest('hex');
|
|
141
|
+
}
|
|
142
|
+
function cleanRevision(root, role) {
|
|
143
|
+
const revision = currentHead(root);
|
|
144
|
+
if (revision === null)
|
|
145
|
+
throw new Error(`deployment fingerprint requires ${role} to be a git repository`);
|
|
146
|
+
if (!isWorkingTreeClean(root))
|
|
147
|
+
throw new Error(`deployment fingerprint requires a clean ${role}`);
|
|
148
|
+
return revision;
|
|
149
|
+
}
|
|
150
|
+
export function captureDeploymentFingerprint(spec) {
|
|
151
|
+
if (spec.preflight === undefined) {
|
|
152
|
+
throw new Error('deployment fingerprint requires an explicit source/built preflight execution binding');
|
|
153
|
+
}
|
|
154
|
+
if (spec.preflight.targetCommandSha256 !== commandSha256(spec.command)) {
|
|
155
|
+
throw new Error('deployment fingerprint refused a preflight binding for a different launch command');
|
|
156
|
+
}
|
|
157
|
+
if (fileSha256(spec.preflight.runnerPath) !== spec.preflight.runnerSha256) {
|
|
158
|
+
throw new Error('deployment fingerprint refused a changed preflight runner');
|
|
159
|
+
}
|
|
160
|
+
if (fileSha256(spec.preflight.installAnchor) !== spec.preflight.installAnchorSha256) {
|
|
161
|
+
throw new Error('deployment fingerprint refused a changed preflight install anchor');
|
|
162
|
+
}
|
|
163
|
+
const credentialRevision = cleanRevision(spec.credentialRepo, 'credential repository');
|
|
164
|
+
const harnessRevision = cleanRevision(spec.harnessRoot, 'harness root');
|
|
165
|
+
return {
|
|
166
|
+
version: 1,
|
|
167
|
+
credentialRevision,
|
|
168
|
+
harnessRevision,
|
|
169
|
+
launchSpecSha256: commandSha256(JSON.stringify(spec)),
|
|
170
|
+
profileSha256: profileRuntimeSha256(spec),
|
|
171
|
+
hostRuntimeSha256: hostRuntimeSha256(spec),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function deploymentFingerprintSha256(fingerprint) {
|
|
175
|
+
return commandSha256(JSON.stringify(fingerprint));
|
|
176
|
+
}
|
|
177
|
+
function credentialCommandSha256(credential) {
|
|
178
|
+
return commandSha256(credential.command);
|
|
179
|
+
}
|
|
180
|
+
function fileSha256(path) {
|
|
181
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
182
|
+
}
|
|
183
|
+
function proofCredentialMatches(proof, credential) {
|
|
184
|
+
return credential !== undefined
|
|
185
|
+
&& credential.revision === proof.credential.revision
|
|
186
|
+
&& credential.recordedAt === proof.credential.recordedAt
|
|
187
|
+
&& credential.scope === proof.credential.scope
|
|
188
|
+
&& credentialCommandSha256(credential) === proof.credential.commandSha256;
|
|
189
|
+
}
|
|
190
|
+
export function verifyProvenDeployment(stateDir, spec) {
|
|
191
|
+
const state = loadState(stateDir);
|
|
192
|
+
const proof = state.provenDeployment;
|
|
193
|
+
if (proof === undefined)
|
|
194
|
+
return { ok: false, reason: 'no proven deployment recorded by a completed restart canary' };
|
|
195
|
+
if (!proofCredentialMatches(proof, state.credential)) {
|
|
196
|
+
return { ok: false, reason: 'proven deployment no longer matches the recorded build/test credential' };
|
|
197
|
+
}
|
|
198
|
+
let current;
|
|
199
|
+
try {
|
|
200
|
+
current = captureDeploymentFingerprint(spec);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
204
|
+
}
|
|
205
|
+
const currentSha256 = deploymentFingerprintSha256(current);
|
|
206
|
+
if (currentSha256 !== proof.fingerprintSha256 || JSON.stringify(current) !== JSON.stringify(proof.fingerprint)) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `proven deployment fingerprint changed (${proof.fingerprintSha256.slice(0, 16)} -> ${currentSha256.slice(0, 16)}) — rebuild, test, and record again`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
reason: `proven deployment valid (${proof.credential.scope} @ ${proof.credential.revision}, fingerprint ${proof.fingerprintSha256.slice(0, 16)})`,
|
|
215
|
+
authorization: {
|
|
216
|
+
version: 1,
|
|
217
|
+
kind: 'proven-deployment',
|
|
218
|
+
revision: proof.credential.revision,
|
|
219
|
+
evidenceSha256: proof.fingerprintSha256,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Fresh evidence wins; an exact prior deployment is the same-launch fallback. */
|
|
224
|
+
export function verifyRestartEvidence(stateDir, spec, maxAgeMinutes, now = Date.now()) {
|
|
225
|
+
const state = loadState(stateDir);
|
|
226
|
+
const credential = state.credential;
|
|
227
|
+
const fresh = verifyCredential(state, currentHead(spec.credentialRepo), now, maxAgeMinutes, isWorkingTreeClean(spec.credentialRepo));
|
|
228
|
+
if (fresh.ok && credential !== undefined) {
|
|
229
|
+
return {
|
|
230
|
+
...fresh,
|
|
231
|
+
authorization: {
|
|
232
|
+
version: 1,
|
|
233
|
+
kind: 'fresh-credential',
|
|
234
|
+
revision: credential.revision,
|
|
235
|
+
evidenceSha256: credentialCommandSha256(credential),
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const proven = verifyProvenDeployment(stateDir, spec);
|
|
240
|
+
if (proven.ok)
|
|
241
|
+
return proven;
|
|
242
|
+
return { ok: false, reason: `${fresh.reason}; pure-restart reuse unavailable: ${proven.reason}` };
|
|
243
|
+
}
|
|
244
|
+
/** Revalidate the exact evidence selected before the scheduled stop. */
|
|
245
|
+
export function verifyRestartAuthorization(stateDir, spec, authorization) {
|
|
246
|
+
const state = loadState(stateDir);
|
|
247
|
+
if (authorization.kind === 'proven-deployment') {
|
|
248
|
+
const proven = verifyProvenDeployment(stateDir, spec);
|
|
249
|
+
if (!proven.ok || proven.authorization?.evidenceSha256 !== authorization.evidenceSha256) {
|
|
250
|
+
return { ok: false, reason: `scheduled proven-deployment authorization changed: ${proven.reason}` };
|
|
251
|
+
}
|
|
252
|
+
return proven;
|
|
253
|
+
}
|
|
254
|
+
const credential = state.credential;
|
|
255
|
+
if (credential === undefined
|
|
256
|
+
|| credential.revision !== authorization.revision
|
|
257
|
+
|| credentialCommandSha256(credential) !== authorization.evidenceSha256) {
|
|
258
|
+
return { ok: false, reason: 'scheduled credential authorization no longer matches durable state' };
|
|
259
|
+
}
|
|
260
|
+
if (currentHead(spec.credentialRepo) !== credential.revision || !isWorkingTreeClean(spec.credentialRepo)) {
|
|
261
|
+
return { ok: false, reason: 'scheduled credential authorization no longer matches a clean repository HEAD' };
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, reason: `scheduled fresh credential still matches ${credential.revision}`, authorization };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Promote a fresh credential after the boot's readiness/canary boundary. A
|
|
267
|
+
* proof-authorized pure restart retains the existing proof unchanged.
|
|
268
|
+
*/
|
|
269
|
+
export function proveCurrentDeployment(stateDir, spec, authorization, now = Date.now()) {
|
|
270
|
+
const authorized = verifyRestartAuthorization(stateDir, spec, authorization);
|
|
271
|
+
if (!authorized.ok)
|
|
272
|
+
return authorized;
|
|
273
|
+
if (authorization.kind === 'proven-deployment') {
|
|
274
|
+
return { ...authorized, reason: `${authorized.reason}; existing deployment proof retained` };
|
|
275
|
+
}
|
|
276
|
+
const state = loadState(stateDir);
|
|
277
|
+
const credential = state.credential;
|
|
278
|
+
if (credential === undefined)
|
|
279
|
+
return { ok: false, reason: 'credential disappeared before deployment proof was recorded' };
|
|
280
|
+
let fingerprint;
|
|
281
|
+
try {
|
|
282
|
+
fingerprint = captureDeploymentFingerprint(spec);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
286
|
+
}
|
|
287
|
+
if (fingerprint.credentialRevision !== credential.revision) {
|
|
288
|
+
return { ok: false, reason: 'deployment fingerprint revision differs from the credential revision' };
|
|
289
|
+
}
|
|
290
|
+
const fingerprintSha256 = deploymentFingerprintSha256(fingerprint);
|
|
291
|
+
const proof = {
|
|
292
|
+
version: 1,
|
|
293
|
+
provenAt: now,
|
|
294
|
+
credential: {
|
|
295
|
+
revision: credential.revision,
|
|
296
|
+
recordedAt: credential.recordedAt,
|
|
297
|
+
scope: credential.scope,
|
|
298
|
+
commandSha256: credentialCommandSha256(credential),
|
|
299
|
+
},
|
|
300
|
+
fingerprint,
|
|
301
|
+
fingerprintSha256,
|
|
302
|
+
};
|
|
303
|
+
setProvenDeployment(stateDir, proof, now);
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
reason: `recorded proven deployment ${fingerprintSha256.slice(0, 16)} for ${credential.revision}`,
|
|
307
|
+
authorization: {
|
|
308
|
+
version: 1,
|
|
309
|
+
kind: 'proven-deployment',
|
|
310
|
+
revision: credential.revision,
|
|
311
|
+
evidenceSha256: fingerprintSha256,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
package/lib/types/exit-agent.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { writeFileSync } from 'node:fs';
|
|
16
16
|
import { isDirectInvocation } from "./defaults.js";
|
|
17
17
|
import { findPidOnPort } from "./processes.js";
|
|
18
|
+
import { registerCurrentTestProcess } from "./test-seam.js";
|
|
18
19
|
/**
|
|
19
20
|
* One exit attempt: SIGTERM the listener on {@link port} and record the
|
|
20
21
|
* outcome. Extracted from the timer so tests can drive it directly.
|
|
@@ -49,5 +50,6 @@ export function exitAgentMain(env = process.env) {
|
|
|
49
50
|
}
|
|
50
51
|
// Direct invocation only: imported (tests, bundling) it must never fire.
|
|
51
52
|
if (isDirectInvocation(import.meta.url)) {
|
|
53
|
+
registerCurrentTestProcess();
|
|
52
54
|
exitAgentMain();
|
|
53
55
|
}
|
package/lib/types/git.d.ts
CHANGED
|
@@ -5,18 +5,27 @@
|
|
|
5
5
|
* @returns the full HEAD sha, or null.
|
|
6
6
|
*/
|
|
7
7
|
export declare function currentHead(repoDir: string): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Porcelain entries for every tracked or untracked working-tree change.
|
|
10
|
+
* `null` means git could not inspect the checkout; callers must fail closed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function workingTreeChanges(repoDir: string): string[] | null;
|
|
13
|
+
/** Whether the checkout has no staged, unstaged, or untracked changes. */
|
|
14
|
+
export declare function isWorkingTreeClean(repoDir: string): boolean;
|
|
8
15
|
/** Result of a checkpoint commit. */
|
|
9
16
|
export type CheckpointCommitResult = {
|
|
10
17
|
ok: true;
|
|
11
18
|
sha: string;
|
|
12
19
|
artifacts: string[];
|
|
20
|
+
createdCommit: boolean;
|
|
13
21
|
} | {
|
|
14
22
|
ok: false;
|
|
15
23
|
error: string;
|
|
16
24
|
};
|
|
17
25
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
26
|
+
* Record a clean HEAD directly, or commit the whole dirty working tree as an
|
|
27
|
+
* explicitly approved checkpoint snapshot. Dirty commits use a temporary git
|
|
28
|
+
* index: a hook/commit failure leaves the caller's real index untouched.
|
|
20
29
|
* @param repoDir - repository directory.
|
|
21
30
|
* @param message - checkpoint commit message.
|
|
22
31
|
* @param artifactPattern - staged paths matching this are reported as
|
|
@@ -24,7 +33,7 @@ export type CheckpointCommitResult = {
|
|
|
24
33
|
* SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
|
|
25
34
|
* @returns the new HEAD sha, or a failure reason.
|
|
26
35
|
*/
|
|
27
|
-
export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp): CheckpointCommitResult;
|
|
36
|
+
export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp, includeDirty?: boolean): CheckpointCommitResult;
|
|
28
37
|
/**
|
|
29
38
|
* Roll the checkout back to a checkpoint commit WITHOUT losing work: the
|
|
30
39
|
* discarded HEAD becomes a `guard-backup-*` branch, and uncommitted tracked
|
package/lib/types/git.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* are synchronous child-process invocations scoped to the repo directory.
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
7
10
|
/**
|
|
8
11
|
* The repository's current HEAD, or null when the directory is not inside a
|
|
9
12
|
* git repository (or git itself is unavailable).
|
|
@@ -23,8 +26,27 @@ export function currentHead(repoDir) {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* Porcelain entries for every tracked or untracked working-tree change.
|
|
30
|
+
* `null` means git could not inspect the checkout; callers must fail closed.
|
|
31
|
+
*/
|
|
32
|
+
export function workingTreeChanges(repoDir) {
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' });
|
|
35
|
+
return out.split('\0').filter(entry => entry !== '');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether the checkout has no staged, unstaged, or untracked changes. */
|
|
42
|
+
export function isWorkingTreeClean(repoDir) {
|
|
43
|
+
const changes = workingTreeChanges(repoDir);
|
|
44
|
+
return changes !== null && changes.length === 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Record a clean HEAD directly, or commit the whole dirty working tree as an
|
|
48
|
+
* explicitly approved checkpoint snapshot. Dirty commits use a temporary git
|
|
49
|
+
* index: a hook/commit failure leaves the caller's real index untouched.
|
|
28
50
|
* @param repoDir - repository directory.
|
|
29
51
|
* @param message - checkpoint commit message.
|
|
30
52
|
* @param artifactPattern - staged paths matching this are reported as
|
|
@@ -32,22 +54,62 @@ export function currentHead(repoDir) {
|
|
|
32
54
|
* SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
|
|
33
55
|
* @returns the new HEAD sha, or a failure reason.
|
|
34
56
|
*/
|
|
35
|
-
export function commitCheckpoint(repoDir, message, artifactPattern) {
|
|
57
|
+
export function commitCheckpoint(repoDir, message, artifactPattern, includeDirty = false) {
|
|
58
|
+
let tempDir;
|
|
36
59
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
const changes = workingTreeChanges(repoDir);
|
|
61
|
+
if (changes === null)
|
|
62
|
+
return { ok: false, error: 'git checkpoint failed: working tree status is unavailable' };
|
|
63
|
+
if (changes.length > 0 && !includeDirty) {
|
|
64
|
+
const shown = changes.slice(0, 10).map(entry => ` ${entry}`).join('\n');
|
|
65
|
+
const more = changes.length > 10 ? `\n … (${changes.length - 10} more)` : '';
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `checkpoint refused: working tree has ${changes.length} change(s); review them, then rerun with --include-dirty to commit the complete snapshot:\n${shown}${more}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const previousHead = currentHead(repoDir);
|
|
72
|
+
if (previousHead === null)
|
|
73
|
+
return { ok: false, error: 'git checkpoint failed: current HEAD is unavailable' };
|
|
74
|
+
if (changes.length === 0)
|
|
75
|
+
return { ok: true, sha: previousHead, artifacts: [], createdCommit: false };
|
|
76
|
+
const indexPathRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
77
|
+
const indexPath = resolve(repoDir, indexPathRaw);
|
|
78
|
+
tempDir = mkdtempSync(join(tmpdir(), 'ankh-guard-index-'));
|
|
79
|
+
const tempIndex = join(tempDir, 'index');
|
|
80
|
+
copyFileSync(indexPath, tempIndex);
|
|
81
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
82
|
+
execFileSync('git', ['add', '-A'], { cwd: repoDir, env, stdio: 'pipe' });
|
|
83
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, env, encoding: 'utf8' });
|
|
39
84
|
const artifacts = artifactPattern === undefined
|
|
40
85
|
? []
|
|
41
86
|
: staged.split('\n').filter(file => artifactPattern.test(file));
|
|
42
|
-
execFileSync('git', ['commit', '
|
|
87
|
+
execFileSync('git', ['commit', '-m', message], { cwd: repoDir, env, stdio: 'pipe' });
|
|
43
88
|
const sha = currentHead(repoDir);
|
|
44
89
|
if (sha === null)
|
|
45
90
|
return { ok: false, error: 'checkpoint commit succeeded but HEAD became unreadable' };
|
|
46
|
-
|
|
91
|
+
try {
|
|
92
|
+
// The complete snapshot is now HEAD; make the real index match it. This
|
|
93
|
+
// is the successful-path equivalent of the old `git add -A && commit`,
|
|
94
|
+
// while the temporary index protected the failure path.
|
|
95
|
+
execFileSync('git', ['reset', '--mixed', 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
try {
|
|
99
|
+
execFileSync('git', ['update-ref', 'HEAD', previousHead, sha], { cwd: repoDir, stdio: 'pipe' });
|
|
100
|
+
}
|
|
101
|
+
catch { /* best effort */ }
|
|
102
|
+
return { ok: false, error: `checkpoint commit could not settle the real index: ${String(error)}` };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, sha, artifacts, createdCommit: true };
|
|
47
105
|
}
|
|
48
106
|
catch (error) {
|
|
49
107
|
return { ok: false, error: `git checkpoint failed: ${String(error)}` };
|
|
50
108
|
}
|
|
109
|
+
finally {
|
|
110
|
+
if (tempDir !== undefined)
|
|
111
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
51
113
|
}
|
|
52
114
|
/**
|
|
53
115
|
* Roll the checkout back to a checkpoint commit WITHOUT losing work: the
|
package/lib/types/index.d.ts
CHANGED
|
@@ -18,7 +18,53 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Context } from '@deepseek-ai/cordis';
|
|
20
20
|
import z from '@deepseek-ai/schemastery';
|
|
21
|
+
import { type RestartRequest, type RestartRequestResult } from './restart-request.ts';
|
|
21
22
|
import { type GuardState, type VerifyResult } from './state.ts';
|
|
23
|
+
/**
|
|
24
|
+
* The slice of a persisted session the preset derivation reads. Structural
|
|
25
|
+
* rather than the rc host's `PresetBearingSession`: the 0.1.2 host deleted
|
|
26
|
+
* that type together with `resolveSessionPreset`.
|
|
27
|
+
*/
|
|
28
|
+
interface PersistedPresetSource {
|
|
29
|
+
header: {
|
|
30
|
+
agentPreset?: string | null;
|
|
31
|
+
};
|
|
32
|
+
events: readonly {
|
|
33
|
+
type: string;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
}[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The two preset-derivation surfaces a host may carry: the rc line exports
|
|
39
|
+
* `resolveSessionPreset`; the 0.1.2 line replaced it (and the
|
|
40
|
+
* `PresetBearingSession` type) with the `agentPresetProjectionDefinition`
|
|
41
|
+
* unit. Probed per call, never from a version string.
|
|
42
|
+
*/
|
|
43
|
+
export interface PresetDerivationSurface {
|
|
44
|
+
agentPresetProjectionDefinition?: {
|
|
45
|
+
init(header: {
|
|
46
|
+
agentPreset?: string | null;
|
|
47
|
+
}): string | null;
|
|
48
|
+
apply(state: string | null, event: {
|
|
49
|
+
type: string;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
}): string | null;
|
|
52
|
+
};
|
|
53
|
+
resolveSessionPreset?: (session: PersistedPresetSource) => string | undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Which preset a session actually runs, newest selection winning. The rc host
|
|
57
|
+
* exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
|
|
58
|
+
* replaced it with `agentPresetProjectionDefinition` (init from the header,
|
|
59
|
+
* fold `agent-preset/selected` events) — same semantics, so the derivation
|
|
60
|
+
* feature-detects either surface and never touches the version string. A host
|
|
61
|
+
* with neither yields undefined: the resume falls back to the deployment's
|
|
62
|
+
* default preset, the same outcome a preset-less session had before.
|
|
63
|
+
* @param host - the agent-presets module namespace, structurally probed.
|
|
64
|
+
* @param session - the session's header and event log.
|
|
65
|
+
* @returns the preset id, or `undefined` when the session names none.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveSessionPreset(host: PresetDerivationSurface, session: PersistedPresetSource): string | undefined;
|
|
22
68
|
/** Plugin configuration. */
|
|
23
69
|
export interface SelfRestartGuardConfig {
|
|
24
70
|
/** Credential freshness window in minutes (default 10). */
|
|
@@ -75,6 +121,7 @@ export type CheckpointResult = {
|
|
|
75
121
|
ok: true;
|
|
76
122
|
sha: string;
|
|
77
123
|
artifacts: string[];
|
|
124
|
+
createdCommit: boolean;
|
|
78
125
|
} | {
|
|
79
126
|
ok: false;
|
|
80
127
|
error: string;
|
|
@@ -90,11 +137,13 @@ export interface SelfRestartGuard {
|
|
|
90
137
|
*/
|
|
91
138
|
verify(): VerifyResult;
|
|
92
139
|
/**
|
|
93
|
-
* Record
|
|
140
|
+
* Record an externally proven green credential for the current clean HEAD.
|
|
141
|
+
* This synchronous service is a trusted orchestrator seam; agent/CLI flows
|
|
142
|
+
* must use `record --run -- PROGRAM` so the guard observes the exit status.
|
|
94
143
|
* @param scope - what passed, e.g. 'build+test'.
|
|
95
144
|
* @param options - optional command that produced the green state.
|
|
96
145
|
* @returns the persisted state including the new credential.
|
|
97
|
-
* @throws outside a git repository
|
|
146
|
+
* @throws outside a git repository or while the checkout is dirty.
|
|
98
147
|
*/
|
|
99
148
|
record(scope: string, options?: {
|
|
100
149
|
command?: string;
|
|
@@ -110,7 +159,9 @@ export interface SelfRestartGuard {
|
|
|
110
159
|
*/
|
|
111
160
|
status(): GuardState;
|
|
112
161
|
/**
|
|
113
|
-
*
|
|
162
|
+
* Remember the existing clean HEAD as a pre-batch checkpoint. Dirty trees
|
|
163
|
+
* are refused; the CLI's reviewed `--include-dirty` path is deliberately
|
|
164
|
+
* unavailable through this convenience service.
|
|
114
165
|
* @param message - batch description; defaults to 'batch snapshot'.
|
|
115
166
|
* @returns the checkpoint commit sha, or a failure reason.
|
|
116
167
|
*/
|
|
@@ -134,6 +185,17 @@ export interface SelfRestartGuard {
|
|
|
134
185
|
canary(options?: {
|
|
135
186
|
port?: number;
|
|
136
187
|
}): Promise<CanaryResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Trigger a guarded restart onto a new launch command (a UI-grade
|
|
190
|
+
* mode/profile switch). Dispatches on supervision: no live watchdog → the
|
|
191
|
+
* restart verb's stop→start→canary; supervised → reconfigure's transactional
|
|
192
|
+
* cutover (the only safe way to change the launch command under a live
|
|
193
|
+
* watchdog). The full gate chain applies — credential, composition
|
|
194
|
+
* preflight, marker/lock — and a refusal never stops the running instance.
|
|
195
|
+
* @param request - start: the successor launch command; profile: the dsh profile to preflight; initiator: the session id the restart report returns to (required — the UI caller knows the real session, never invent one).
|
|
196
|
+
* @returns the structured verdict; terminal hint text never crosses this seam.
|
|
197
|
+
*/
|
|
198
|
+
requestRestart(request: RestartRequest): Promise<RestartRequestResult>;
|
|
137
199
|
}
|
|
138
200
|
declare module '@deepseek-ai/cordis' {
|
|
139
201
|
interface Context {
|
|
@@ -150,4 +212,5 @@ export declare const inject: string[];
|
|
|
150
212
|
* @param config - validated plugin config.
|
|
151
213
|
*/
|
|
152
214
|
export declare function apply(ctx: Context, config: SelfRestartGuardConfig): void;
|
|
215
|
+
export {};
|
|
153
216
|
//# sourceMappingURL=index.d.ts.map
|