@xioflow/kernel 0.3.0 → 0.5.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/README.md +174 -15
- package/dist/capability/check.d.ts +32 -0
- package/dist/capability/check.d.ts.map +1 -0
- package/dist/capability/check.js +136 -0
- package/dist/capability/check.js.map +1 -0
- package/dist/capability/index.d.ts +3 -0
- package/dist/capability/index.d.ts.map +1 -0
- package/dist/capability/index.js +3 -0
- package/dist/capability/index.js.map +1 -0
- package/dist/capability/path-utils.d.ts +11 -0
- package/dist/capability/path-utils.d.ts.map +1 -0
- package/dist/capability/path-utils.js +46 -0
- package/dist/capability/path-utils.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +94 -0
- package/dist/cli.js.map +1 -0
- package/dist/confinement/bubblewrap.d.ts +8 -0
- package/dist/confinement/bubblewrap.d.ts.map +1 -0
- package/dist/confinement/bubblewrap.js +45 -0
- package/dist/confinement/bubblewrap.js.map +1 -0
- package/dist/confinement/detector.d.ts +4 -0
- package/dist/confinement/detector.d.ts.map +1 -0
- package/dist/confinement/detector.js +35 -0
- package/dist/confinement/detector.js.map +1 -0
- package/dist/confinement/index.d.ts +5 -0
- package/dist/confinement/index.d.ts.map +1 -0
- package/dist/confinement/index.js +5 -0
- package/dist/confinement/index.js.map +1 -0
- package/dist/confinement/sandbox-exec.d.ts +8 -0
- package/dist/confinement/sandbox-exec.d.ts.map +1 -0
- package/dist/confinement/sandbox-exec.js +30 -0
- package/dist/confinement/sandbox-exec.js.map +1 -0
- package/dist/confinement/srt.d.ts +8 -0
- package/dist/confinement/srt.d.ts.map +1 -0
- package/dist/confinement/srt.js +41 -0
- package/dist/confinement/srt.js.map +1 -0
- package/dist/domain.d.ts +26 -2
- package/dist/domain.d.ts.map +1 -1
- package/dist/domain.js +192 -18
- package/dist/domain.js.map +1 -1
- package/dist/driver/node-driver.d.ts.map +1 -1
- package/dist/driver/node-driver.js +42 -61
- package/dist/driver/node-driver.js.map +1 -1
- package/dist/driver/reaper-driver.d.ts +62 -0
- package/dist/driver/reaper-driver.d.ts.map +1 -0
- package/dist/driver/reaper-driver.js +371 -0
- package/dist/driver/reaper-driver.js.map +1 -0
- package/dist/driver/spawn-support.d.ts +4 -0
- package/dist/driver/spawn-support.d.ts.map +1 -0
- package/dist/driver/spawn-support.js +31 -0
- package/dist/driver/spawn-support.js.map +1 -0
- package/dist/driver/types.d.ts +19 -2
- package/dist/driver/types.d.ts.map +1 -1
- package/dist/fault/crashpoint.d.ts +8 -0
- package/dist/fault/crashpoint.d.ts.map +1 -0
- package/dist/fault/crashpoint.js +37 -0
- package/dist/fault/crashpoint.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +233 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +32 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +255 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/native/darwin-arm64/xioflow-reaper +0 -0
- package/dist/native/darwin-x64/xioflow-reaper +0 -0
- package/dist/native/linux-arm64/xioflow-reaper +0 -0
- package/dist/native/linux-x64/xioflow-reaper +0 -0
- package/dist/otel/otlp.d.ts +37 -0
- package/dist/otel/otlp.d.ts.map +1 -0
- package/dist/otel/otlp.js +224 -0
- package/dist/otel/otlp.js.map +1 -0
- package/dist/quick-run.d.ts.map +1 -1
- package/dist/quick-run.js +5 -12
- package/dist/quick-run.js.map +1 -1
- package/dist/recovery/engine.d.ts +7 -0
- package/dist/recovery/engine.d.ts.map +1 -1
- package/dist/recovery/engine.js +176 -4
- package/dist/recovery/engine.js.map +1 -1
- package/dist/snapshot/git-shadow.d.ts +57 -0
- package/dist/snapshot/git-shadow.d.ts.map +1 -0
- package/dist/snapshot/git-shadow.js +364 -0
- package/dist/snapshot/git-shadow.js.map +1 -0
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.d.ts.map +1 -1
- package/dist/store/schema.js +31 -0
- package/dist/store/schema.js.map +1 -1
- package/dist/store/sqlite.d.ts +14 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +150 -58
- package/dist/store/sqlite.js.map +1 -1
- package/dist/supervisor/admission.d.ts +26 -0
- package/dist/supervisor/admission.d.ts.map +1 -0
- package/dist/supervisor/admission.js +98 -0
- package/dist/supervisor/admission.js.map +1 -0
- package/dist/supervisor/drainer.d.ts +17 -0
- package/dist/supervisor/drainer.d.ts.map +1 -0
- package/dist/supervisor/drainer.js +200 -0
- package/dist/supervisor/drainer.js.map +1 -0
- package/dist/supervisor/finalize.d.ts +21 -0
- package/dist/supervisor/finalize.d.ts.map +1 -0
- package/dist/supervisor/finalize.js +66 -0
- package/dist/supervisor/finalize.js.map +1 -0
- package/dist/supervisor/fingerprint.d.ts +8 -0
- package/dist/supervisor/fingerprint.d.ts.map +1 -0
- package/dist/supervisor/fingerprint.js +54 -0
- package/dist/supervisor/fingerprint.js.map +1 -0
- package/dist/supervisor/process-monitor.d.ts +9 -0
- package/dist/supervisor/process-monitor.d.ts.map +1 -0
- package/dist/supervisor/process-monitor.js +230 -0
- package/dist/supervisor/process-monitor.js.map +1 -0
- package/dist/supervisor/process-output.d.ts +27 -0
- package/dist/supervisor/process-output.d.ts.map +1 -0
- package/dist/supervisor/process-output.js +102 -0
- package/dist/supervisor/process-output.js.map +1 -0
- package/dist/supervisor/process-run.d.ts +30 -0
- package/dist/supervisor/process-run.d.ts.map +1 -0
- package/dist/supervisor/process-run.js +191 -0
- package/dist/supervisor/process-run.js.map +1 -0
- package/dist/supervisor/replay.d.ts +14 -0
- package/dist/supervisor/replay.d.ts.map +1 -0
- package/dist/supervisor/replay.js +70 -0
- package/dist/supervisor/replay.js.map +1 -0
- package/dist/supervisor/rollback.d.ts +8 -0
- package/dist/supervisor/rollback.d.ts.map +1 -0
- package/dist/supervisor/rollback.js +153 -0
- package/dist/supervisor/rollback.js.map +1 -0
- package/dist/supervisor/service.d.ts +24 -0
- package/dist/supervisor/service.d.ts.map +1 -0
- package/dist/supervisor/service.js +588 -0
- package/dist/supervisor/service.js.map +1 -0
- package/dist/supervisor/snapshot-ops.d.ts +36 -0
- package/dist/supervisor/snapshot-ops.d.ts.map +1 -0
- package/dist/supervisor/snapshot-ops.js +243 -0
- package/dist/supervisor/snapshot-ops.js.map +1 -0
- package/dist/supervisor/supervisor.d.ts +62 -30
- package/dist/supervisor/supervisor.d.ts.map +1 -1
- package/dist/supervisor/supervisor.js +164 -842
- package/dist/supervisor/supervisor.js.map +1 -1
- package/dist/supervisor/types.d.ts +64 -0
- package/dist/supervisor/types.d.ts.map +1 -0
- package/dist/supervisor/types.js +2 -0
- package/dist/supervisor/types.js.map +1 -0
- package/dist/testing/contract-suite.d.ts.map +1 -1
- package/dist/testing/contract-suite.js +619 -12
- package/dist/testing/contract-suite.js.map +1 -1
- package/dist/types.d.ts +161 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/workspace/read-tracking.d.ts +23 -0
- package/dist/workspace/read-tracking.d.ts.map +1 -0
- package/dist/workspace/read-tracking.js +66 -0
- package/dist/workspace/read-tracking.js.map +1 -0
- package/dist/workspace/transactions.d.ts +89 -0
- package/dist/workspace/transactions.d.ts.map +1 -0
- package/dist/workspace/transactions.js +277 -0
- package/dist/workspace/transactions.js.map +1 -0
- package/package.json +8 -2
|
@@ -2,8 +2,26 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
5
|
+
import util from 'node:util';
|
|
6
|
+
import { spawn, execFile } from 'node:child_process';
|
|
7
|
+
const execFileAsync = util.promisify(execFile);
|
|
8
|
+
import { ExecutionDomain, SqliteStore, ProcessSupervisor, RecoveryEngine, ResourceConflictError, UnsupportedCapabilityError, EpochFencedError, OperationNotActiveError, DuplicateOperationError, OperationIdConflictError, RecoveryRequiredError, computeInputFingerprint, normalizeResourceName, CapabilityViolationError, } from '../index.js';
|
|
9
|
+
/** 进程是否已真正消失(容许 init / 持有者回收僵尸的短暂延迟)。 */
|
|
10
|
+
async function waitUntilGone(pid, timeoutMs) {
|
|
11
|
+
const deadline = Date.now() + timeoutMs;
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err.code === 'ESRCH')
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
if (Date.now() >= deadline)
|
|
21
|
+
return false;
|
|
22
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
7
25
|
function ensureTaskAndRun(domain, taskId, runId) {
|
|
8
26
|
const store = domain.getStore();
|
|
9
27
|
if (!store.getTask(taskId)) {
|
|
@@ -369,7 +387,7 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
369
387
|
expect(grandchildAliveAfter).toBe(false);
|
|
370
388
|
await executePromise;
|
|
371
389
|
});
|
|
372
|
-
it('契约 10:
|
|
390
|
+
it('契约 10: 逃逸后代诚实上报:停止结论与事实一致,无法确认时如实标记 cannot_determine', async () => {
|
|
373
391
|
const { supervisor, domain, tempDir } = ctx;
|
|
374
392
|
ensureTaskAndRun(domain, 'task-c10', 'run-c10');
|
|
375
393
|
const escapedPidFile = path.join(tempDir, 'escaped.pid');
|
|
@@ -421,9 +439,19 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
421
439
|
}
|
|
422
440
|
catch { }
|
|
423
441
|
expect(escapedAliveBefore).toBe(true);
|
|
424
|
-
//
|
|
442
|
+
// 等待采样轮询器(每 35ms 周期)至少完成一次后代树捕获
|
|
443
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
444
|
+
// 调用停止流水线。结论必须与事实一致:
|
|
445
|
+
// - 能持有整棵树的驱动(如 Linux 子收割者)可以真的停掉逃逸者,此时逃逸者必须确已消失;
|
|
446
|
+
// - 做不到的驱动必须如实上报 cannot_determine + residualPids,绝不能声称 confirmed_stopped。
|
|
425
447
|
const cancelRes = await supervisor.cancelOperation('op-c10-escape', 1000);
|
|
426
|
-
|
|
448
|
+
if (cancelRes.stopped === 'confirmed_stopped') {
|
|
449
|
+
expect(await waitUntilGone(escapedPid, 2000)).toBe(true);
|
|
450
|
+
const result = await executePromise;
|
|
451
|
+
expect(result.status).toBe('cancelled');
|
|
452
|
+
expect(domain.isResourceLocked('res:c10-lock')).toBe(false);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
427
455
|
expect(cancelRes.stopped).toBe('cannot_determine');
|
|
428
456
|
expect(cancelRes.residualPids).toBeDefined();
|
|
429
457
|
expect(cancelRes.residualPids).toContain(escapedPid);
|
|
@@ -696,7 +724,7 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
696
724
|
expect(throwing.stdout).toBe('captured-anyway');
|
|
697
725
|
expect(throwing.streamCallbackError).toBe('consumer projection failed');
|
|
698
726
|
});
|
|
699
|
-
it('契约 19: 超时 +
|
|
727
|
+
it('契约 19: 超时 + 逃逸后代持有管道,操作在有界时间内结清,不挂到逃逸进程退出', async () => {
|
|
700
728
|
const { supervisor, domain, tempDir } = ctx;
|
|
701
729
|
ensureTaskAndRun(domain, 'task-c19', 'run-c19');
|
|
702
730
|
const subPidFile = path.join(tempDir, 'sub-c19.pid');
|
|
@@ -726,13 +754,26 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
726
754
|
});
|
|
727
755
|
const duration = Date.now() - startedAt;
|
|
728
756
|
expect(duration).toBeLessThan(3500);
|
|
729
|
-
|
|
730
|
-
expect(
|
|
731
|
-
|
|
732
|
-
|
|
757
|
+
const subPid = fs.existsSync(subPidFile) ? parseInt(fs.readFileSync(subPidFile, 'utf8'), 10) : 0;
|
|
758
|
+
expect(subPid).toBeGreaterThan(0);
|
|
759
|
+
try {
|
|
760
|
+
// executeProcess 的声明类型不含 indeterminate,运行时结果可能是它
|
|
761
|
+
if (result.status === 'indeterminate') {
|
|
762
|
+
// 逃逸者未被确认停止:保留租约等待裁决
|
|
763
|
+
expect(domain.isResourceLocked('res:c19-bounded')).toBe(true);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
// 驱动确认停掉了整棵树:只有逃逸者确已消失才允许按超时失败结清并释放租约
|
|
767
|
+
expect(result.status).toBe('failed');
|
|
768
|
+
expect(result.terminationReason).toBe('timed_out');
|
|
769
|
+
expect(await waitUntilGone(subPid, 2000)).toBe(true);
|
|
770
|
+
expect(domain.isResourceLocked('res:c19-bounded')).toBe(false);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
// 清理逃逸孙进程
|
|
733
775
|
try {
|
|
734
|
-
|
|
735
|
-
process.kill(pid, 'SIGKILL');
|
|
776
|
+
process.kill(subPid, 'SIGKILL');
|
|
736
777
|
}
|
|
737
778
|
catch { }
|
|
738
779
|
}
|
|
@@ -802,6 +843,8 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
802
843
|
expect(domain.isResourceLocked('res:c21')).toBe(false);
|
|
803
844
|
const result = domain.getStore().getOperation(opId).result;
|
|
804
845
|
expect(result.status).toBe('failed');
|
|
846
|
+
// 退出事实未被观察到:结局未知,不能被当作「确实失败、可换 opId 重试」
|
|
847
|
+
expect(result.terminationReason).toBe('exit_unobserved');
|
|
805
848
|
expect(result.kind === 'process' ? result.stderr : '').toContain('reaped during recovery');
|
|
806
849
|
}
|
|
807
850
|
finally {
|
|
@@ -1163,6 +1206,570 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
1163
1206
|
expect(res.replayed).toBe(true);
|
|
1164
1207
|
expect(res.runId).toBe('run-c49');
|
|
1165
1208
|
});
|
|
1209
|
+
it('契约 50: service 实例 stdinMode: "stream" 双向通信;stdout 直通不进内存保留;停止走标准流水线', async () => {
|
|
1210
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1211
|
+
const runId = 'run-c50';
|
|
1212
|
+
ensureTaskAndRun(domain, 'task-c50', runId);
|
|
1213
|
+
const script = `
|
|
1214
|
+
const readline = require('readline');
|
|
1215
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
1216
|
+
rl.on('line', (line) => console.log('REPLY:' + line));
|
|
1217
|
+
`;
|
|
1218
|
+
const handle = await supervisor.startService({
|
|
1219
|
+
serviceId: 'service-c50',
|
|
1220
|
+
runId,
|
|
1221
|
+
command: {
|
|
1222
|
+
execPath: process.execPath,
|
|
1223
|
+
args: ['-e', script],
|
|
1224
|
+
cwd: tempDir,
|
|
1225
|
+
},
|
|
1226
|
+
readiness: 'spawned',
|
|
1227
|
+
});
|
|
1228
|
+
expect(handle.currentInstanceOpId).toBe('service-c50#1');
|
|
1229
|
+
await handle.ready;
|
|
1230
|
+
// 验证双向 stdin/stdout 流式交互
|
|
1231
|
+
const receivedLines = [];
|
|
1232
|
+
handle.stdout.on('data', (chunk) => {
|
|
1233
|
+
const lines = chunk.toString().split('\n');
|
|
1234
|
+
for (const l of lines) {
|
|
1235
|
+
if (l.trim())
|
|
1236
|
+
receivedLines.push(l.trim());
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
handle.stdin.write('ping-50\n');
|
|
1240
|
+
const start = Date.now();
|
|
1241
|
+
while (!receivedLines.includes('REPLY:ping-50') && Date.now() - start < 3000) {
|
|
1242
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1243
|
+
}
|
|
1244
|
+
expect(receivedLines).toContain('REPLY:ping-50');
|
|
1245
|
+
// 停止 service 走标准流水线
|
|
1246
|
+
await handle.stop(1000);
|
|
1247
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1248
|
+
const startEvt = events.find((e) => e.type === 'SERVICE_STARTED' && e.payload?.serviceId === 'service-c50');
|
|
1249
|
+
const readyEvt = events.find((e) => e.type === 'SERVICE_READY' && e.payload?.serviceId === 'service-c50');
|
|
1250
|
+
const stopEvt = events.find((e) => e.type === 'SERVICE_STOPPED' && e.payload?.serviceId === 'service-c50');
|
|
1251
|
+
expect(startEvt).toBeDefined();
|
|
1252
|
+
expect(readyEvt).toBeDefined();
|
|
1253
|
+
expect(stopEvt).toBeDefined();
|
|
1254
|
+
});
|
|
1255
|
+
it('契约 51: service on-failure 按退避重启且不超过上限;每次重启是新实例 op 并写 journal;超限 => failed', async () => {
|
|
1256
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1257
|
+
const runId = 'run-c51';
|
|
1258
|
+
ensureTaskAndRun(domain, 'task-c51', runId);
|
|
1259
|
+
const script = `
|
|
1260
|
+
const readline = require('readline');
|
|
1261
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
1262
|
+
rl.once('line', () => process.exit(42));
|
|
1263
|
+
`;
|
|
1264
|
+
const handle = await supervisor.startService({
|
|
1265
|
+
serviceId: 'service-c51',
|
|
1266
|
+
runId,
|
|
1267
|
+
command: {
|
|
1268
|
+
execPath: process.execPath,
|
|
1269
|
+
args: ['-e', script],
|
|
1270
|
+
cwd: tempDir,
|
|
1271
|
+
},
|
|
1272
|
+
readiness: 'spawned',
|
|
1273
|
+
restart: { policy: 'on-failure', maxRestarts: 2, backoffMs: 50 },
|
|
1274
|
+
});
|
|
1275
|
+
await handle.ready;
|
|
1276
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#1');
|
|
1277
|
+
// 退出 instance #1
|
|
1278
|
+
handle.stdin.write('die-1\n');
|
|
1279
|
+
const start1 = Date.now();
|
|
1280
|
+
while (handle.currentInstanceOpId !== 'service-c51#2' && Date.now() - start1 < 3000) {
|
|
1281
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1282
|
+
}
|
|
1283
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#2');
|
|
1284
|
+
// 退出 instance #2
|
|
1285
|
+
handle.stdin.write('die-2\n');
|
|
1286
|
+
const start2 = Date.now();
|
|
1287
|
+
while (handle.currentInstanceOpId !== 'service-c51#3' && Date.now() - start2 < 3000) {
|
|
1288
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1289
|
+
}
|
|
1290
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#3');
|
|
1291
|
+
// 退出 instance #3(达到上限)
|
|
1292
|
+
handle.stdin.write('die-3\n');
|
|
1293
|
+
const start3 = Date.now();
|
|
1294
|
+
let events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1295
|
+
while (!events.some((e) => e.type === 'SERVICE_FAILED' && e.payload?.serviceId === 'service-c51') && Date.now() - start3 < 3000) {
|
|
1296
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1297
|
+
events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1298
|
+
}
|
|
1299
|
+
events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1300
|
+
const restarts = events.filter((e) => e.type === 'SERVICE_RESTARTED' && e.payload?.serviceId === 'service-c51');
|
|
1301
|
+
expect(restarts.length).toBe(2);
|
|
1302
|
+
const failed = events.find((e) => e.type === 'SERVICE_FAILED' && e.payload?.serviceId === 'service-c51');
|
|
1303
|
+
expect(failed).toBeDefined();
|
|
1304
|
+
expect(failed?.payload?.maxRestartsExceeded).toBe(true);
|
|
1305
|
+
});
|
|
1306
|
+
it('契约 52: 宿主崩溃后 service 实例被核验并清场,恢复引擎不自动重启', async () => {
|
|
1307
|
+
const { supervisor, domain, driver, tempDir } = ctx;
|
|
1308
|
+
const runId = 'run-c52';
|
|
1309
|
+
ensureTaskAndRun(domain, 'task-c52', runId);
|
|
1310
|
+
const resource = 'res:c52-service';
|
|
1311
|
+
const script = `
|
|
1312
|
+
console.log('READY');
|
|
1313
|
+
setInterval(() => {}, 1000);
|
|
1314
|
+
`;
|
|
1315
|
+
const handle = await supervisor.startService({
|
|
1316
|
+
serviceId: 'service-c52',
|
|
1317
|
+
runId,
|
|
1318
|
+
command: { execPath: process.execPath, args: ['-e', script], cwd: tempDir },
|
|
1319
|
+
requiredResources: [resource],
|
|
1320
|
+
readiness: { stdoutLine: /^READY$/ },
|
|
1321
|
+
restart: { policy: 'on-failure', maxRestarts: 3, backoffMs: 50 },
|
|
1322
|
+
});
|
|
1323
|
+
await handle.ready;
|
|
1324
|
+
expect(domain.isResourceLocked(resource)).toBe(true);
|
|
1325
|
+
const op = domain.getStore().getOperation('service-c52#1');
|
|
1326
|
+
const childPid = op?.processIdentity?.pid;
|
|
1327
|
+
expect(childPid).toBeDefined();
|
|
1328
|
+
// 模拟宿主崩溃:关闭 domain,但不主动停止 service 实例
|
|
1329
|
+
domain.close();
|
|
1330
|
+
// 新宿主 acquire 并执行恢复
|
|
1331
|
+
const restartedDomain = ExecutionDomain.acquire(tempDir, domain.domainId);
|
|
1332
|
+
expect(restartedDomain.isResourceLocked(resource)).toBe(true);
|
|
1333
|
+
const recoveryEngine = new RecoveryEngine(restartedDomain, driver);
|
|
1334
|
+
const report = await recoveryEngine.recover();
|
|
1335
|
+
expect(report.recoveredServices).toBeDefined();
|
|
1336
|
+
const srvReport = report.recoveredServices.find((s) => s.serviceId === 'service-c52');
|
|
1337
|
+
expect(srvReport).toBeDefined();
|
|
1338
|
+
expect(srvReport?.resourcesReleased).toBe(true);
|
|
1339
|
+
expect(srvReport?.instanceOpIds).toContain('service-c52#1');
|
|
1340
|
+
// 资源租约已完全释放
|
|
1341
|
+
expect(restartedDomain.isResourceLocked(resource)).toBe(false);
|
|
1342
|
+
// 进程已停止
|
|
1343
|
+
let isAlive = true;
|
|
1344
|
+
try {
|
|
1345
|
+
process.kill(childPid, 0);
|
|
1346
|
+
}
|
|
1347
|
+
catch (err) {
|
|
1348
|
+
if (err.code === 'ESRCH')
|
|
1349
|
+
isAlive = false;
|
|
1350
|
+
}
|
|
1351
|
+
expect(isAlive).toBe(false);
|
|
1352
|
+
// 不会自动重启新实例
|
|
1353
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1354
|
+
const allOps = restartedDomain.getStore().getOperationsByRun(runId);
|
|
1355
|
+
expect(allOps.find((o) => o.id === 'service-c52#2')).toBeUndefined();
|
|
1356
|
+
restartedDomain.close();
|
|
1357
|
+
});
|
|
1358
|
+
it('契约 28: 回滚后指纹核验:restored / partial / failed 如实区分', async () => {
|
|
1359
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1360
|
+
const repoDir = path.join(tempDir, 'repo-c28');
|
|
1361
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1362
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1363
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1364
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1365
|
+
fs.writeFileSync(path.join(repoDir, 'main.ts'), 'version 1;\n');
|
|
1366
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1367
|
+
await execFileAsync('git', ['commit', '-m', 'v1'], { cwd: repoDir });
|
|
1368
|
+
ensureTaskAndRun(domain, 'task-c28', 'run-c28');
|
|
1369
|
+
// 捕获快照
|
|
1370
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1371
|
+
runId: 'run-c28',
|
|
1372
|
+
opId: 'op-c28-snap',
|
|
1373
|
+
roots: [repoDir],
|
|
1374
|
+
});
|
|
1375
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1376
|
+
const snapId = snapRes.snapshot.id;
|
|
1377
|
+
// 修改工作区文件并增加新文件
|
|
1378
|
+
fs.writeFileSync(path.join(repoDir, 'main.ts'), 'version 2 modified;\n');
|
|
1379
|
+
fs.writeFileSync(path.join(repoDir, 'extra.txt'), 'extra untracked\n');
|
|
1380
|
+
// 执行回滚
|
|
1381
|
+
const rbRes = await supervisor.rollback({
|
|
1382
|
+
runId: 'run-c28',
|
|
1383
|
+
opId: 'op-c28-rollback-1',
|
|
1384
|
+
snapshotId: snapId,
|
|
1385
|
+
});
|
|
1386
|
+
expect(rbRes.status).toBe('restored');
|
|
1387
|
+
expect(fs.readFileSync(path.join(repoDir, 'main.ts'), 'utf8')).toBe('version 1;\n');
|
|
1388
|
+
expect(fs.existsSync(path.join(repoDir, 'extra.txt'))).toBe(false);
|
|
1389
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(repoDir)}`)).toBe(false);
|
|
1390
|
+
});
|
|
1391
|
+
it('契约 29: 无写入限制时回滚 coverage 不得为 complete,outOfScopeEffects = "possible"', async () => {
|
|
1392
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1393
|
+
const repoDir = path.join(tempDir, 'repo-c29');
|
|
1394
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1395
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1396
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1397
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1398
|
+
fs.writeFileSync(path.join(repoDir, 'code.js'), 'let x = 1;\n');
|
|
1399
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1400
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1401
|
+
ensureTaskAndRun(domain, 'task-c29', 'run-c29');
|
|
1402
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1403
|
+
runId: 'run-c29',
|
|
1404
|
+
opId: 'op-c29-snap',
|
|
1405
|
+
roots: [repoDir],
|
|
1406
|
+
});
|
|
1407
|
+
const snapId = snapRes.snapshot.id;
|
|
1408
|
+
// 执行一个普通不受限的 process 操作
|
|
1409
|
+
await supervisor.executeProcess({
|
|
1410
|
+
runId: 'run-c29',
|
|
1411
|
+
opId: 'op-c29-unconfined',
|
|
1412
|
+
name: 'unconfined-cmd',
|
|
1413
|
+
command: { execPath: process.execPath, args: ['-e', 'console.log(123)'], cwd: repoDir },
|
|
1414
|
+
requiredResources: [],
|
|
1415
|
+
});
|
|
1416
|
+
// 回滚
|
|
1417
|
+
const rbRes = await supervisor.rollback({
|
|
1418
|
+
runId: 'run-c29',
|
|
1419
|
+
opId: 'op-c29-rollback',
|
|
1420
|
+
snapshotId: snapId,
|
|
1421
|
+
});
|
|
1422
|
+
// 契约 #29 严禁在无写入限制时声明 complete
|
|
1423
|
+
expect(rbRes.coverage).not.toBe('complete');
|
|
1424
|
+
expect(rbRes.coverage).toBe('declared_roots');
|
|
1425
|
+
expect(rbRes.outOfScopeEffects).toBe('possible');
|
|
1426
|
+
});
|
|
1427
|
+
it('契约 30: 回滚与目标根目录上的活跃 / 未裁决操作互斥', async () => {
|
|
1428
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1429
|
+
const repoDir = path.join(tempDir, 'repo-c30');
|
|
1430
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1431
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1432
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1433
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1434
|
+
fs.writeFileSync(path.join(repoDir, 'index.js'), '// initial\n');
|
|
1435
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1436
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1437
|
+
ensureTaskAndRun(domain, 'task-c30', 'run-c30');
|
|
1438
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1439
|
+
runId: 'run-c30',
|
|
1440
|
+
opId: 'op-c30-snap',
|
|
1441
|
+
roots: [repoDir],
|
|
1442
|
+
});
|
|
1443
|
+
const snapId = snapRes.snapshot.id;
|
|
1444
|
+
// 在目标根上持有写租约并启动长时间操作
|
|
1445
|
+
const writeRes = `workspace:write:${path.resolve(repoDir)}`;
|
|
1446
|
+
const activePromise = supervisor.executeProcess({
|
|
1447
|
+
runId: 'run-c30',
|
|
1448
|
+
opId: 'op-c30-holding',
|
|
1449
|
+
name: 'holder',
|
|
1450
|
+
command: { execPath: process.execPath, args: ['-e', 'setTimeout(() => {}, 2000)'], cwd: repoDir },
|
|
1451
|
+
requiredResources: [writeRes],
|
|
1452
|
+
});
|
|
1453
|
+
// 等待进程启动获得租约
|
|
1454
|
+
const normRes = normalizeResourceName(writeRes);
|
|
1455
|
+
while (!domain.isResourceLocked(normRes)) {
|
|
1456
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1457
|
+
}
|
|
1458
|
+
// 尝试在活跃操作持有目标目录时回滚,必须被拒绝并给出持有者诊断
|
|
1459
|
+
await expect(supervisor.rollback({
|
|
1460
|
+
runId: 'run-c30',
|
|
1461
|
+
opId: 'op-c30-rollback-conflict',
|
|
1462
|
+
snapshotId: snapId,
|
|
1463
|
+
})).rejects.toThrow(ResourceConflictError);
|
|
1464
|
+
await supervisor.cancelOperation('op-c30-holding');
|
|
1465
|
+
await activePromise;
|
|
1466
|
+
});
|
|
1467
|
+
it('契约 31: 回滚中途崩溃:恢复按指纹判定 restored 或 indeterminate', async () => {
|
|
1468
|
+
const { supervisor, domain, driver, tempDir } = ctx;
|
|
1469
|
+
const repoDir = path.join(tempDir, 'repo-c31');
|
|
1470
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1471
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1472
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1473
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1474
|
+
fs.writeFileSync(path.join(repoDir, 'tracked.txt'), 'clean v1\n');
|
|
1475
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1476
|
+
await execFileAsync('git', ['commit', '-m', 'clean v1'], { cwd: repoDir });
|
|
1477
|
+
ensureTaskAndRun(domain, 'task-c31', 'run-c31');
|
|
1478
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1479
|
+
runId: 'run-c31',
|
|
1480
|
+
opId: 'op-c31-snap',
|
|
1481
|
+
roots: [repoDir],
|
|
1482
|
+
});
|
|
1483
|
+
const snapId = snapRes.snapshot.id;
|
|
1484
|
+
// 场景 A: 模拟回滚中途宿主崩溃,但工作区指纹与快照一致
|
|
1485
|
+
const writeRes = `workspace:write:${path.resolve(repoDir)}`;
|
|
1486
|
+
domain.getStore().registerOperationIntent({
|
|
1487
|
+
id: 'op-c31-crash-match',
|
|
1488
|
+
runId: 'run-c31',
|
|
1489
|
+
kind: 'rollback',
|
|
1490
|
+
name: `rollback:${snapId}`,
|
|
1491
|
+
inputFingerprint: 'fp-c31',
|
|
1492
|
+
requiredResources: [writeRes],
|
|
1493
|
+
mutationRoots: [path.resolve(repoDir)],
|
|
1494
|
+
outputRef: snapId,
|
|
1495
|
+
status: 'intent_registered',
|
|
1496
|
+
}, domain.domainId);
|
|
1497
|
+
domain.getStore().updateOperationStatus('op-c31-crash-match', 'active');
|
|
1498
|
+
domain.allocateResources('op-c31-crash-match', [writeRes]);
|
|
1499
|
+
const recoveryA = new RecoveryEngine(domain, driver);
|
|
1500
|
+
const repA = await recoveryA.recover();
|
|
1501
|
+
const recA = repA.recoveredOperations.find((o) => o.opId === 'op-c31-crash-match');
|
|
1502
|
+
expect(recA?.resourcesReleased).toBe(true);
|
|
1503
|
+
const opA = domain.getStore().getOperation('op-c31-crash-match');
|
|
1504
|
+
expect(opA?.result?.status).toBe('restored');
|
|
1505
|
+
expect(domain.isResourceLocked(writeRes)).toBe(false);
|
|
1506
|
+
// 场景 B: 模拟回滚中途宿主崩溃,且工作区被修改导致指纹不匹配
|
|
1507
|
+
ensureTaskAndRun(domain, 'task-c31', 'run-c31-b');
|
|
1508
|
+
fs.writeFileSync(path.join(repoDir, 'tracked.txt'), 'corrupted mid-rollback\n');
|
|
1509
|
+
domain.getStore().registerOperationIntent({
|
|
1510
|
+
id: 'op-c31-crash-mismatch',
|
|
1511
|
+
runId: 'run-c31-b',
|
|
1512
|
+
kind: 'rollback',
|
|
1513
|
+
name: `rollback:${snapId}`,
|
|
1514
|
+
inputFingerprint: 'fp-c31-b',
|
|
1515
|
+
requiredResources: [writeRes],
|
|
1516
|
+
mutationRoots: [path.resolve(repoDir)],
|
|
1517
|
+
outputRef: snapId,
|
|
1518
|
+
status: 'intent_registered',
|
|
1519
|
+
}, domain.domainId);
|
|
1520
|
+
domain.getStore().updateOperationStatus('op-c31-crash-mismatch', 'active');
|
|
1521
|
+
domain.allocateResources('op-c31-crash-mismatch', [writeRes]);
|
|
1522
|
+
const repB = await recoveryA.recover();
|
|
1523
|
+
const recB = repB.recoveredOperations.find((o) => o.opId === 'op-c31-crash-mismatch');
|
|
1524
|
+
expect(recB?.resourcesReleased).toBe(false);
|
|
1525
|
+
const opB = domain.getStore().getOperation('op-c31-crash-mismatch');
|
|
1526
|
+
expect(opB?.result?.status).toBe('indeterminate');
|
|
1527
|
+
expect(domain.isResourceLocked(writeRes)).toBe(true);
|
|
1528
|
+
// 清理资源
|
|
1529
|
+
domain.internalReleaseResources('op-c31-crash-mismatch', [writeRes]);
|
|
1530
|
+
});
|
|
1531
|
+
it('契约 32: git-shadow 快照不改动用户 index、HEAD 与分支,并如实声明 worktree_non_ignored', async () => {
|
|
1532
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1533
|
+
const repoDir = path.join(tempDir, 'repo-c32');
|
|
1534
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1535
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1536
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1537
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1538
|
+
fs.writeFileSync(path.join(repoDir, 'base.txt'), 'base\n');
|
|
1539
|
+
fs.writeFileSync(path.join(repoDir, '.gitignore'), '.env\n');
|
|
1540
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1541
|
+
await execFileAsync('git', ['commit', '-m', 'base commit'], { cwd: repoDir });
|
|
1542
|
+
// 构造用户正在工作的工作区现场
|
|
1543
|
+
fs.writeFileSync(path.join(repoDir, 'staged.txt'), 'user staged change\n');
|
|
1544
|
+
await execFileAsync('git', ['add', 'staged.txt'], { cwd: repoDir });
|
|
1545
|
+
fs.writeFileSync(path.join(repoDir, 'unstaged.txt'), 'user unstaged change\n');
|
|
1546
|
+
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=donttouch\n');
|
|
1547
|
+
const statusBefore = (await execFileAsync('git', ['status', '--porcelain'], { cwd: repoDir })).stdout;
|
|
1548
|
+
const stageBefore = (await execFileAsync('git', ['ls-files', '--stage'], { cwd: repoDir })).stdout;
|
|
1549
|
+
const headBefore = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).stdout;
|
|
1550
|
+
const branchBefore = (await execFileAsync('git', ['branch'], { cwd: repoDir })).stdout;
|
|
1551
|
+
ensureTaskAndRun(domain, 'task-c32', 'run-c32');
|
|
1552
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1553
|
+
runId: 'run-c32',
|
|
1554
|
+
opId: 'op-c32',
|
|
1555
|
+
roots: [repoDir],
|
|
1556
|
+
});
|
|
1557
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1558
|
+
expect(snapRes.snapshot?.coverage).toBe('worktree_non_ignored');
|
|
1559
|
+
const statusAfter = (await execFileAsync('git', ['status', '--porcelain'], { cwd: repoDir })).stdout;
|
|
1560
|
+
const stageAfter = (await execFileAsync('git', ['ls-files', '--stage'], { cwd: repoDir })).stdout;
|
|
1561
|
+
const headAfter = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).stdout;
|
|
1562
|
+
const branchAfter = (await execFileAsync('git', ['branch'], { cwd: repoDir })).stdout;
|
|
1563
|
+
expect(statusAfter).toBe(statusBefore);
|
|
1564
|
+
expect(stageAfter).toBe(stageBefore);
|
|
1565
|
+
expect(headAfter).toBe(headBefore);
|
|
1566
|
+
expect(branchAfter).toBe(branchBefore);
|
|
1567
|
+
});
|
|
1568
|
+
it('契约 53: SnapshotRef.journalSeq 与捕获事件 seq 一致;可按 seq 查询不晚于它的最近快照', async () => {
|
|
1569
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1570
|
+
const repoDir = path.join(tempDir, 'repo-c53');
|
|
1571
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1572
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1573
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1574
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1575
|
+
fs.writeFileSync(path.join(repoDir, 'step.txt'), 'step 0\n');
|
|
1576
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1577
|
+
await execFileAsync('git', ['commit', '-m', 'step 0'], { cwd: repoDir });
|
|
1578
|
+
ensureTaskAndRun(domain, 'task-c53', 'run-c53');
|
|
1579
|
+
// 捕获第 1 个快照
|
|
1580
|
+
const snap1 = await supervisor.captureSnapshot({
|
|
1581
|
+
runId: 'run-c53',
|
|
1582
|
+
opId: 'snap-1',
|
|
1583
|
+
roots: [repoDir],
|
|
1584
|
+
});
|
|
1585
|
+
const events1 = domain.getStore().getJournalEvents(domain.domainId);
|
|
1586
|
+
const capEvent1 = events1.find((e) => e.type === 'SNAPSHOT_CAPTURED' && e.payload?.snapshotId === 'snap-1');
|
|
1587
|
+
expect(capEvent1).toBeDefined();
|
|
1588
|
+
expect(snap1.snapshot?.journalSeq).toBe(capEvent1?.seq);
|
|
1589
|
+
fs.writeFileSync(path.join(repoDir, 'step.txt'), 'step 1\n');
|
|
1590
|
+
// 捕获第 2 个快照
|
|
1591
|
+
const snap2 = await supervisor.captureSnapshot({
|
|
1592
|
+
runId: 'run-c53',
|
|
1593
|
+
opId: 'snap-2',
|
|
1594
|
+
roots: [repoDir],
|
|
1595
|
+
});
|
|
1596
|
+
const events2 = domain.getStore().getJournalEvents(domain.domainId);
|
|
1597
|
+
const capEvent2 = events2.find((e) => e.type === 'SNAPSHOT_CAPTURED' && e.payload?.snapshotId === 'snap-2');
|
|
1598
|
+
expect(snap2.snapshot?.journalSeq).toBe(capEvent2?.seq);
|
|
1599
|
+
// 查询 seq 不晚于 capEvent1.seq 的快照 => 必须返回 snap-1
|
|
1600
|
+
const foundSnap1 = domain.getStore().findSnapshotAtOrBefore(domain.domainId, capEvent1.seq);
|
|
1601
|
+
expect(foundSnap1?.id).toBe('snap-1');
|
|
1602
|
+
// 查询 seq 不晚于 capEvent2.seq 的快照 => 必须返回 snap-2
|
|
1603
|
+
const foundSnap2 = domain.getStore().findSnapshotAtOrBefore(domain.domainId, capEvent2.seq);
|
|
1604
|
+
expect(foundSnap2?.id).toBe('snap-2');
|
|
1605
|
+
});
|
|
1606
|
+
it('契约 54: materialize 生成的工作区独立;源工作区、用户 index、HEAD 与分支不变;dematerialize 后无残留 worktree', async () => {
|
|
1607
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1608
|
+
const repoDir = path.join(tempDir, 'repo-c54');
|
|
1609
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1610
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1611
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1612
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1613
|
+
fs.writeFileSync(path.join(repoDir, 'entry.ts'), 'export const val = 42;\n');
|
|
1614
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1615
|
+
await execFileAsync('git', ['commit', '-m', 'entry commit'], { cwd: repoDir });
|
|
1616
|
+
ensureTaskAndRun(domain, 'task-c54', 'run-c54');
|
|
1617
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1618
|
+
runId: 'run-c54',
|
|
1619
|
+
opId: 'snap-c54',
|
|
1620
|
+
roots: [repoDir],
|
|
1621
|
+
});
|
|
1622
|
+
const forkDir = path.join(tempDir, 'fork-c54');
|
|
1623
|
+
const { worktreePath } = await supervisor.materialize('snap-c54', forkDir);
|
|
1624
|
+
expect(worktreePath).toBe(forkDir);
|
|
1625
|
+
expect(fs.existsSync(path.join(forkDir, 'entry.ts'))).toBe(true);
|
|
1626
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(forkDir)}`)).toBe(true);
|
|
1627
|
+
// dematerialize
|
|
1628
|
+
await supervisor.dematerialize(forkDir);
|
|
1629
|
+
expect(fs.existsSync(forkDir)).toBe(false);
|
|
1630
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(forkDir)}`)).toBe(false);
|
|
1631
|
+
const { stdout: wtList } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoDir });
|
|
1632
|
+
expect(wtList).not.toContain(forkDir);
|
|
1633
|
+
});
|
|
1634
|
+
it('契约 55: 准入校验:过期 / 撤销 / 旧 epoch / 越界资源 / 越界路径抛 CapabilityViolationError 并记 CAPABILITY_REJECTED;缺省推导生效并记 CAPABILITY_USED', async () => {
|
|
1635
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1636
|
+
ensureTaskAndRun(domain, 'task-c55', 'run-c55');
|
|
1637
|
+
const allowedDir = path.join(tempDir, 'allowed');
|
|
1638
|
+
const outsideDir = path.join(tempDir, 'outside');
|
|
1639
|
+
fs.mkdirSync(allowedDir, { recursive: true });
|
|
1640
|
+
fs.mkdirSync(outsideDir, { recursive: true });
|
|
1641
|
+
// 1. 签发有效 capability
|
|
1642
|
+
const cap = domain.issueCapability({
|
|
1643
|
+
write: [allowedDir],
|
|
1644
|
+
exclusive: ['res:allowed-1'],
|
|
1645
|
+
}, 'tester', 60000);
|
|
1646
|
+
// 2. 越界资源
|
|
1647
|
+
await expect(supervisor.executeProcess({
|
|
1648
|
+
runId: 'run-c55',
|
|
1649
|
+
opId: 'op-c55-res-violation',
|
|
1650
|
+
name: 'violating-proc',
|
|
1651
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: allowedDir },
|
|
1652
|
+
requiredResources: ['res:unauthorized-res'],
|
|
1653
|
+
capabilityId: cap.id,
|
|
1654
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1655
|
+
// 3. 越界路径 (cwd 在 allowedDir 之外)
|
|
1656
|
+
await expect(supervisor.executeProcess({
|
|
1657
|
+
runId: 'run-c55',
|
|
1658
|
+
opId: 'op-c55-path-violation',
|
|
1659
|
+
name: 'violating-path',
|
|
1660
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: outsideDir },
|
|
1661
|
+
capabilityId: cap.id,
|
|
1662
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1663
|
+
// 4. 撤销 capability
|
|
1664
|
+
domain.revokeCapability(cap.id, 'test_revocation');
|
|
1665
|
+
await expect(supervisor.executeProcess({
|
|
1666
|
+
runId: 'run-c55',
|
|
1667
|
+
opId: 'op-c55-revoked',
|
|
1668
|
+
name: 'revoked-proc',
|
|
1669
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: allowedDir },
|
|
1670
|
+
capabilityId: cap.id,
|
|
1671
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1672
|
+
// 验证 CAPABILITY_REJECTED 事件已入 journal
|
|
1673
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1674
|
+
const rejectedEvents = events.filter((e) => e.type === 'CAPABILITY_REJECTED');
|
|
1675
|
+
expect(rejectedEvents.length).toBeGreaterThanOrEqual(3);
|
|
1676
|
+
// 5. 缺省 capability 推导生效与 CAPABILITY_USED 记录
|
|
1677
|
+
const validCap = domain.issueCapability({
|
|
1678
|
+
write: [tempDir],
|
|
1679
|
+
exclusive: ['res:default-1'],
|
|
1680
|
+
}, 'tester', 60000);
|
|
1681
|
+
const res = await supervisor.executeProcess({
|
|
1682
|
+
runId: 'run-c55',
|
|
1683
|
+
opId: 'op-c55-success',
|
|
1684
|
+
name: 'valid-proc',
|
|
1685
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: tempDir },
|
|
1686
|
+
requiredResources: ['res:default-1'],
|
|
1687
|
+
capabilityId: validCap.id,
|
|
1688
|
+
});
|
|
1689
|
+
expect(res.status).toBe('succeeded');
|
|
1690
|
+
expect(res.capabilityId).toBe(validCap.id);
|
|
1691
|
+
const usedEvents = domain.getStore().getJournalEvents(domain.domainId).filter((e) => e.type === 'CAPABILITY_USED');
|
|
1692
|
+
expect(usedEvents.some((e) => e.payload?.capabilityId === validCap.id)).toBe(true);
|
|
1693
|
+
});
|
|
1694
|
+
it('契约 56: 写入限制驱动:受限写外部路径失败;快照后全受限回滚声明 complete,混入不受限声明 declared_roots', async () => {
|
|
1695
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1696
|
+
const repoDir = path.join(tempDir, 'repo-c56');
|
|
1697
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1698
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1699
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1700
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1701
|
+
fs.writeFileSync(path.join(repoDir, 'init.txt'), 'init\n');
|
|
1702
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1703
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1704
|
+
ensureTaskAndRun(domain, 'task-c56', 'run-c56');
|
|
1705
|
+
const mockDriver = {
|
|
1706
|
+
name: 'mock-confinement',
|
|
1707
|
+
wrap: (cmd) => cmd,
|
|
1708
|
+
};
|
|
1709
|
+
// 1. 签发受限 capability (针对 repoDir)
|
|
1710
|
+
const confinedCap = domain.issueCapability({
|
|
1711
|
+
write: [repoDir],
|
|
1712
|
+
exclusive: [`workspace:write:${path.resolve(repoDir)}`],
|
|
1713
|
+
}, 'tester', 60000);
|
|
1714
|
+
// 捕获快照
|
|
1715
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1716
|
+
runId: 'run-c56',
|
|
1717
|
+
opId: 'snap-c56',
|
|
1718
|
+
capabilityId: confinedCap.id,
|
|
1719
|
+
});
|
|
1720
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1721
|
+
// 在受限下运行进程写文件
|
|
1722
|
+
const p1 = await supervisor.executeProcess({
|
|
1723
|
+
runId: 'run-c56',
|
|
1724
|
+
opId: 'op-c56-confined',
|
|
1725
|
+
name: 'write-in-repo',
|
|
1726
|
+
command: {
|
|
1727
|
+
execPath: process.execPath,
|
|
1728
|
+
args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(path.join(repoDir, 'a.txt'))}, 'a');`],
|
|
1729
|
+
cwd: repoDir,
|
|
1730
|
+
},
|
|
1731
|
+
capabilityId: confinedCap.id,
|
|
1732
|
+
confinementDriver: mockDriver,
|
|
1733
|
+
});
|
|
1734
|
+
expect(p1.status).toBe('succeeded');
|
|
1735
|
+
expect(p1.confined).toBe(true);
|
|
1736
|
+
// 全部 op 受限,回滚 coverage 必须为 complete
|
|
1737
|
+
const rollback1 = await supervisor.rollback({
|
|
1738
|
+
runId: 'run-c56',
|
|
1739
|
+
opId: 'rb-c56-1',
|
|
1740
|
+
snapshotId: 'snap-c56',
|
|
1741
|
+
capabilityId: confinedCap.id,
|
|
1742
|
+
});
|
|
1743
|
+
expect(rollback1.coverage).toBe('complete');
|
|
1744
|
+
expect(rollback1.outOfScopeEffects).toBe('none_possible');
|
|
1745
|
+
// 2. 混入不受限操作
|
|
1746
|
+
ensureTaskAndRun(domain, 'task-c56', 'run-c56-unconfined');
|
|
1747
|
+
const snap2 = await supervisor.captureSnapshot({
|
|
1748
|
+
runId: 'run-c56-unconfined',
|
|
1749
|
+
opId: 'snap-c56-2',
|
|
1750
|
+
roots: [repoDir],
|
|
1751
|
+
});
|
|
1752
|
+
const p2 = await supervisor.executeProcess({
|
|
1753
|
+
runId: 'run-c56-unconfined',
|
|
1754
|
+
opId: 'op-c56-unconfined',
|
|
1755
|
+
name: 'write-unconfined',
|
|
1756
|
+
command: {
|
|
1757
|
+
execPath: process.execPath,
|
|
1758
|
+
args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(path.join(repoDir, 'b.txt'))}, 'b');`],
|
|
1759
|
+
cwd: repoDir,
|
|
1760
|
+
},
|
|
1761
|
+
requiredResources: [`workspace:write:${path.resolve(repoDir)}`],
|
|
1762
|
+
});
|
|
1763
|
+
expect(p2.confined).toBeFalsy();
|
|
1764
|
+
// 混入不受限操作,回滚 coverage 退化为 declared_roots
|
|
1765
|
+
const rollback2 = await supervisor.rollback({
|
|
1766
|
+
runId: 'run-c56-unconfined',
|
|
1767
|
+
opId: 'rb-c56-2',
|
|
1768
|
+
snapshotId: 'snap-c56-2',
|
|
1769
|
+
});
|
|
1770
|
+
expect(rollback2.coverage).toBe('declared_roots');
|
|
1771
|
+
expect(rollback2.outOfScopeEffects).toBe('possible');
|
|
1772
|
+
});
|
|
1166
1773
|
});
|
|
1167
1774
|
}
|
|
1168
1775
|
//# sourceMappingURL=contract-suite.js.map
|