@xioflow/kernel 0.2.0 → 0.4.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 +116 -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/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 +46 -2
- package/dist/domain.d.ts.map +1 -1
- package/dist/domain.js +209 -18
- package/dist/domain.js.map +1 -1
- package/dist/driver/node-driver.d.ts.map +1 -1
- package/dist/driver/node-driver.js +46 -36
- package/dist/driver/node-driver.js.map +1 -1
- package/dist/driver/types.d.ts +13 -0
- package/dist/driver/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/quick-run.d.ts +27 -0
- package/dist/quick-run.d.ts.map +1 -0
- package/dist/quick-run.js +75 -0
- package/dist/quick-run.js.map +1 -0
- package/dist/recovery/engine.d.ts +7 -0
- package/dist/recovery/engine.d.ts.map +1 -1
- package/dist/recovery/engine.js +174 -4
- package/dist/recovery/engine.js.map +1 -1
- package/dist/snapshot/git-shadow.d.ts +49 -0
- package/dist/snapshot/git-shadow.d.ts.map +1 -0
- package/dist/snapshot/git-shadow.js +359 -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 +17 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +193 -58
- package/dist/store/sqlite.js.map +1 -1
- 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/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/supervisor.d.ts +62 -4
- package/dist/supervisor/supervisor.d.ts.map +1 -1
- package/dist/supervisor/supervisor.js +1037 -562
- package/dist/supervisor/supervisor.js.map +1 -1
- package/dist/testing/contract-suite.d.ts.map +1 -1
- package/dist/testing/contract-suite.js +748 -2
- package/dist/testing/contract-suite.js.map +1 -1
- package/dist/types.d.ts +175 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +39 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,8 +2,10 @@ 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';
|
|
7
9
|
function ensureTaskAndRun(domain, taskId, runId) {
|
|
8
10
|
const store = domain.getStore();
|
|
9
11
|
if (!store.getTask(taskId)) {
|
|
@@ -421,6 +423,8 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
421
423
|
}
|
|
422
424
|
catch { }
|
|
423
425
|
expect(escapedAliveBefore).toBe(true);
|
|
426
|
+
// 等待采样轮询器(每 35ms 周期)至少完成一次后代树捕获
|
|
427
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
424
428
|
// 调用停止流水线:NodePlatformDriver 真实后代树扫描探测到逃逸残留
|
|
425
429
|
const cancelRes = await supervisor.cancelOperation('op-c10-escape', 1000);
|
|
426
430
|
// 真实上报 stopped: cannot_determine,且 residualPids 包含逃逸孤儿 PID
|
|
@@ -985,6 +989,748 @@ export function defineContractTestSuite(consumerName, createContext) {
|
|
|
985
989
|
expect(pruneResult.retained).toContain(activeFile);
|
|
986
990
|
expect(fs.existsSync(activeFile)).toBe(true);
|
|
987
991
|
});
|
|
992
|
+
it('契约 45: 同 opId 同指纹重放返回已记录结果,不产生新进程,写 OPERATION_REPLAYED', async () => {
|
|
993
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
994
|
+
ensureTaskAndRun(domain, 'task-c45', 'run-c45');
|
|
995
|
+
const counterFile = path.join(tempDir, 'counter-c45.log');
|
|
996
|
+
const options = {
|
|
997
|
+
runId: 'run-c45',
|
|
998
|
+
opId: 'op-c45-recorded',
|
|
999
|
+
name: 'op-c45',
|
|
1000
|
+
requiredResources: [],
|
|
1001
|
+
command: {
|
|
1002
|
+
execPath: process.execPath,
|
|
1003
|
+
args: ['-e', `require('fs').appendFileSync(process.argv[1], '1\\n'); console.log('c45-out');`, counterFile],
|
|
1004
|
+
cwd: tempDir,
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
const res1 = await supervisor.executeProcess(options);
|
|
1008
|
+
expect(res1.status).toBe('succeeded');
|
|
1009
|
+
expect(res1.replayed).toBeUndefined();
|
|
1010
|
+
expect(res1.stdout).toContain('c45-out');
|
|
1011
|
+
const res2 = await supervisor.executeProcess(options);
|
|
1012
|
+
expect(res2.status).toBe('succeeded');
|
|
1013
|
+
expect(res2.replayed).toBe(true);
|
|
1014
|
+
expect(res2.stdout).toContain('c45-out');
|
|
1015
|
+
// 验证副作用仅发生 1 次
|
|
1016
|
+
const lines = fs.readFileSync(counterFile, 'utf8').trim().split('\n');
|
|
1017
|
+
expect(lines.length).toBe(1);
|
|
1018
|
+
// journal 记录 OPERATION_REPLAYED
|
|
1019
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1020
|
+
const replayEvt = events.find((e) => e.type === 'OPERATION_REPLAYED' && e.operationId === 'op-c45-recorded');
|
|
1021
|
+
expect(replayEvt).toBeDefined();
|
|
1022
|
+
expect(replayEvt?.payload.mode).toBe('recorded');
|
|
1023
|
+
});
|
|
1024
|
+
it('契约 46: 同 opId 不同指纹 => OperationIdConflictError,不改动已有 op 的任何事实', async () => {
|
|
1025
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1026
|
+
ensureTaskAndRun(domain, 'task-c46', 'run-c46');
|
|
1027
|
+
const options1 = {
|
|
1028
|
+
runId: 'run-c46',
|
|
1029
|
+
opId: 'op-c46-conflict',
|
|
1030
|
+
name: 'op-c46-orig',
|
|
1031
|
+
requiredResources: [],
|
|
1032
|
+
command: {
|
|
1033
|
+
execPath: process.execPath,
|
|
1034
|
+
args: ['-e', 'console.log("c46-orig");'],
|
|
1035
|
+
cwd: tempDir,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
const res1 = await supervisor.executeProcess(options1);
|
|
1039
|
+
expect(res1.status).toBe('succeeded');
|
|
1040
|
+
const options2 = {
|
|
1041
|
+
runId: 'run-c46',
|
|
1042
|
+
opId: 'op-c46-conflict',
|
|
1043
|
+
name: 'op-c46-conflict',
|
|
1044
|
+
requiredResources: [],
|
|
1045
|
+
command: {
|
|
1046
|
+
execPath: process.execPath,
|
|
1047
|
+
args: ['-e', 'console.log("c46-diff");'], // 不同 args
|
|
1048
|
+
cwd: tempDir,
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
await expect(supervisor.executeProcess(options2)).rejects.toThrow(OperationIdConflictError);
|
|
1052
|
+
const op = domain.getStore().getOperation('op-c46-conflict');
|
|
1053
|
+
expect(op?.status).toBe('done');
|
|
1054
|
+
expect(op?.result?.status).toBe('succeeded');
|
|
1055
|
+
});
|
|
1056
|
+
it('契约 47: 重放命中在飞 op => 两个调用拿到同一结果,进程只启动一次', async () => {
|
|
1057
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1058
|
+
ensureTaskAndRun(domain, 'task-c47', 'run-c47');
|
|
1059
|
+
const counterFile = path.join(tempDir, 'counter-c47.log');
|
|
1060
|
+
const options = {
|
|
1061
|
+
runId: 'run-c47',
|
|
1062
|
+
opId: 'op-c47-inflight',
|
|
1063
|
+
name: 'op-c47',
|
|
1064
|
+
requiredResources: [],
|
|
1065
|
+
command: {
|
|
1066
|
+
execPath: process.execPath,
|
|
1067
|
+
args: ['-e', `require('fs').appendFileSync(process.argv[1], '1\\n'); setTimeout(() => console.log('c47-out'), 300);`, counterFile],
|
|
1068
|
+
cwd: tempDir,
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
const [res1, res2] = await Promise.all([
|
|
1072
|
+
supervisor.executeProcess(options),
|
|
1073
|
+
supervisor.executeProcess(options),
|
|
1074
|
+
]);
|
|
1075
|
+
expect(res1.status).toBe('succeeded');
|
|
1076
|
+
expect(res2.status).toBe('succeeded');
|
|
1077
|
+
expect(res2.replayed).toBe(true);
|
|
1078
|
+
const lines = fs.readFileSync(counterFile, 'utf8').trim().split('\n');
|
|
1079
|
+
expect(lines.length).toBe(1);
|
|
1080
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1081
|
+
const replayEvt = events.find((e) => e.type === 'OPERATION_REPLAYED' && e.operationId === 'op-c47-inflight');
|
|
1082
|
+
expect(replayEvt?.payload.mode).toBe('joined');
|
|
1083
|
+
});
|
|
1084
|
+
it('契约 48: 重放命中 indeterminate => 原样返回,不重跑,租约保持', async () => {
|
|
1085
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1086
|
+
ensureTaskAndRun(domain, 'task-c48', 'run-c48');
|
|
1087
|
+
const opId = 'op-c48-indet';
|
|
1088
|
+
const store = domain.getStore();
|
|
1089
|
+
const options = {
|
|
1090
|
+
runId: 'run-c48',
|
|
1091
|
+
opId,
|
|
1092
|
+
name: 'test-indet',
|
|
1093
|
+
requiredResources: ['res:c48'],
|
|
1094
|
+
command: {
|
|
1095
|
+
execPath: process.execPath,
|
|
1096
|
+
args: ['-e', 'process.exit(0)'],
|
|
1097
|
+
cwd: tempDir,
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
store.registerOperationIntent({
|
|
1101
|
+
id: opId,
|
|
1102
|
+
runId: 'run-c48',
|
|
1103
|
+
kind: 'process',
|
|
1104
|
+
name: 'test-indet',
|
|
1105
|
+
inputFingerprint: computeInputFingerprint(options),
|
|
1106
|
+
requiredResources: ['res:c48'],
|
|
1107
|
+
status: 'pending',
|
|
1108
|
+
}, domain.domainId);
|
|
1109
|
+
const indetResult = {
|
|
1110
|
+
kind: 'process',
|
|
1111
|
+
status: 'indeterminate',
|
|
1112
|
+
exitCode: null,
|
|
1113
|
+
signal: null,
|
|
1114
|
+
stdout: '',
|
|
1115
|
+
stderr: 'indeterminate residual',
|
|
1116
|
+
isTruncated: false,
|
|
1117
|
+
identityVerification: 'cannot_determine',
|
|
1118
|
+
durationMs: 50,
|
|
1119
|
+
completedAt: new Date().toISOString(),
|
|
1120
|
+
};
|
|
1121
|
+
store.recordOperationResult(opId, indetResult, false);
|
|
1122
|
+
const res = await supervisor.executeProcess(options);
|
|
1123
|
+
expect(res.status).toBe('indeterminate');
|
|
1124
|
+
expect(res.replayed).toBe(true);
|
|
1125
|
+
const leases = store.getPersistedResourceLeases(domain.domainId);
|
|
1126
|
+
expect(leases.some((l) => l.operationId === opId && l.resourceId === 'res:c48')).toBe(true);
|
|
1127
|
+
});
|
|
1128
|
+
it('契约 49: 有意图无身份的崩溃现场:未恢复时提交抛 RecoveryRequiredError;恢复后结清重放命中已记录事实', async () => {
|
|
1129
|
+
const { supervisor, domain, driver, tempDir } = ctx;
|
|
1130
|
+
ensureTaskAndRun(domain, 'task-c49', 'run-c49');
|
|
1131
|
+
const opId = 'op-c49-unrecovered';
|
|
1132
|
+
const store = domain.getStore();
|
|
1133
|
+
const options = {
|
|
1134
|
+
runId: 'run-c49',
|
|
1135
|
+
opId,
|
|
1136
|
+
name: 'test-unrecovered',
|
|
1137
|
+
requiredResources: [],
|
|
1138
|
+
command: {
|
|
1139
|
+
execPath: process.execPath,
|
|
1140
|
+
args: ['-e', 'console.log(1)'],
|
|
1141
|
+
cwd: tempDir,
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
// 构造崩溃残留:intent_registered 且不在内存活跃表中
|
|
1145
|
+
store.registerOperationIntent({
|
|
1146
|
+
id: opId,
|
|
1147
|
+
runId: 'run-c49',
|
|
1148
|
+
kind: 'process',
|
|
1149
|
+
name: 'test-unrecovered',
|
|
1150
|
+
inputFingerprint: computeInputFingerprint(options),
|
|
1151
|
+
requiredResources: [],
|
|
1152
|
+
status: 'intent_registered',
|
|
1153
|
+
}, domain.domainId);
|
|
1154
|
+
// 未调用 recover() 前调用,抛 RecoveryRequiredError
|
|
1155
|
+
await expect(supervisor.executeProcess(options)).rejects.toThrow(RecoveryRequiredError);
|
|
1156
|
+
// 执行崩溃恢复
|
|
1157
|
+
const recoveryEngine = new RecoveryEngine(domain, driver);
|
|
1158
|
+
await recoveryEngine.recover();
|
|
1159
|
+
// 恢复后该意图被收敛为 failed(never spawned),新 Run 再次发起重放应命中已记录结果
|
|
1160
|
+
ensureTaskAndRun(domain, 'task-c49', 'run-c49-new');
|
|
1161
|
+
const replayOptions = {
|
|
1162
|
+
...options,
|
|
1163
|
+
runId: 'run-c49-new',
|
|
1164
|
+
};
|
|
1165
|
+
const res = await supervisor.executeProcess(replayOptions);
|
|
1166
|
+
expect(res.status).toBe('failed');
|
|
1167
|
+
expect(res.replayed).toBe(true);
|
|
1168
|
+
expect(res.runId).toBe('run-c49');
|
|
1169
|
+
});
|
|
1170
|
+
it('契约 50: service 实例 stdinMode: "stream" 双向通信;stdout 直通不进内存保留;停止走标准流水线', async () => {
|
|
1171
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1172
|
+
const runId = 'run-c50';
|
|
1173
|
+
ensureTaskAndRun(domain, 'task-c50', runId);
|
|
1174
|
+
const script = `
|
|
1175
|
+
const readline = require('readline');
|
|
1176
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
1177
|
+
rl.on('line', (line) => console.log('REPLY:' + line));
|
|
1178
|
+
`;
|
|
1179
|
+
const handle = await supervisor.startService({
|
|
1180
|
+
serviceId: 'service-c50',
|
|
1181
|
+
runId,
|
|
1182
|
+
command: {
|
|
1183
|
+
execPath: process.execPath,
|
|
1184
|
+
args: ['-e', script],
|
|
1185
|
+
cwd: tempDir,
|
|
1186
|
+
},
|
|
1187
|
+
readiness: 'spawned',
|
|
1188
|
+
});
|
|
1189
|
+
expect(handle.currentInstanceOpId).toBe('service-c50#1');
|
|
1190
|
+
await handle.ready;
|
|
1191
|
+
// 验证双向 stdin/stdout 流式交互
|
|
1192
|
+
const receivedLines = [];
|
|
1193
|
+
handle.stdout.on('data', (chunk) => {
|
|
1194
|
+
const lines = chunk.toString().split('\n');
|
|
1195
|
+
for (const l of lines) {
|
|
1196
|
+
if (l.trim())
|
|
1197
|
+
receivedLines.push(l.trim());
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
handle.stdin.write('ping-50\n');
|
|
1201
|
+
const start = Date.now();
|
|
1202
|
+
while (!receivedLines.includes('REPLY:ping-50') && Date.now() - start < 3000) {
|
|
1203
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1204
|
+
}
|
|
1205
|
+
expect(receivedLines).toContain('REPLY:ping-50');
|
|
1206
|
+
// 停止 service 走标准流水线
|
|
1207
|
+
await handle.stop(1000);
|
|
1208
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1209
|
+
const startEvt = events.find((e) => e.type === 'SERVICE_STARTED' && e.payload?.serviceId === 'service-c50');
|
|
1210
|
+
const readyEvt = events.find((e) => e.type === 'SERVICE_READY' && e.payload?.serviceId === 'service-c50');
|
|
1211
|
+
const stopEvt = events.find((e) => e.type === 'SERVICE_STOPPED' && e.payload?.serviceId === 'service-c50');
|
|
1212
|
+
expect(startEvt).toBeDefined();
|
|
1213
|
+
expect(readyEvt).toBeDefined();
|
|
1214
|
+
expect(stopEvt).toBeDefined();
|
|
1215
|
+
});
|
|
1216
|
+
it('契约 51: service on-failure 按退避重启且不超过上限;每次重启是新实例 op 并写 journal;超限 => failed', async () => {
|
|
1217
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1218
|
+
const runId = 'run-c51';
|
|
1219
|
+
ensureTaskAndRun(domain, 'task-c51', runId);
|
|
1220
|
+
const script = `
|
|
1221
|
+
const readline = require('readline');
|
|
1222
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
1223
|
+
rl.once('line', () => process.exit(42));
|
|
1224
|
+
`;
|
|
1225
|
+
const handle = await supervisor.startService({
|
|
1226
|
+
serviceId: 'service-c51',
|
|
1227
|
+
runId,
|
|
1228
|
+
command: {
|
|
1229
|
+
execPath: process.execPath,
|
|
1230
|
+
args: ['-e', script],
|
|
1231
|
+
cwd: tempDir,
|
|
1232
|
+
},
|
|
1233
|
+
readiness: 'spawned',
|
|
1234
|
+
restart: { policy: 'on-failure', maxRestarts: 2, backoffMs: 50 },
|
|
1235
|
+
});
|
|
1236
|
+
await handle.ready;
|
|
1237
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#1');
|
|
1238
|
+
// 退出 instance #1
|
|
1239
|
+
handle.stdin.write('die-1\n');
|
|
1240
|
+
const start1 = Date.now();
|
|
1241
|
+
while (handle.currentInstanceOpId !== 'service-c51#2' && Date.now() - start1 < 3000) {
|
|
1242
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1243
|
+
}
|
|
1244
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#2');
|
|
1245
|
+
// 退出 instance #2
|
|
1246
|
+
handle.stdin.write('die-2\n');
|
|
1247
|
+
const start2 = Date.now();
|
|
1248
|
+
while (handle.currentInstanceOpId !== 'service-c51#3' && Date.now() - start2 < 3000) {
|
|
1249
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1250
|
+
}
|
|
1251
|
+
expect(handle.currentInstanceOpId).toBe('service-c51#3');
|
|
1252
|
+
// 退出 instance #3(达到上限)
|
|
1253
|
+
handle.stdin.write('die-3\n');
|
|
1254
|
+
const start3 = Date.now();
|
|
1255
|
+
let events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1256
|
+
while (!events.some((e) => e.type === 'SERVICE_FAILED' && e.payload?.serviceId === 'service-c51') && Date.now() - start3 < 3000) {
|
|
1257
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1258
|
+
events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1259
|
+
}
|
|
1260
|
+
events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1261
|
+
const restarts = events.filter((e) => e.type === 'SERVICE_RESTARTED' && e.payload?.serviceId === 'service-c51');
|
|
1262
|
+
expect(restarts.length).toBe(2);
|
|
1263
|
+
const failed = events.find((e) => e.type === 'SERVICE_FAILED' && e.payload?.serviceId === 'service-c51');
|
|
1264
|
+
expect(failed).toBeDefined();
|
|
1265
|
+
expect(failed?.payload?.maxRestartsExceeded).toBe(true);
|
|
1266
|
+
});
|
|
1267
|
+
it('契约 52: 宿主崩溃后 service 实例被核验并清场,恢复引擎不自动重启', async () => {
|
|
1268
|
+
const { supervisor, domain, driver, tempDir } = ctx;
|
|
1269
|
+
const runId = 'run-c52';
|
|
1270
|
+
ensureTaskAndRun(domain, 'task-c52', runId);
|
|
1271
|
+
const resource = 'res:c52-service';
|
|
1272
|
+
const script = `
|
|
1273
|
+
console.log('READY');
|
|
1274
|
+
setInterval(() => {}, 1000);
|
|
1275
|
+
`;
|
|
1276
|
+
const handle = await supervisor.startService({
|
|
1277
|
+
serviceId: 'service-c52',
|
|
1278
|
+
runId,
|
|
1279
|
+
command: { execPath: process.execPath, args: ['-e', script], cwd: tempDir },
|
|
1280
|
+
requiredResources: [resource],
|
|
1281
|
+
readiness: { stdoutLine: /^READY$/ },
|
|
1282
|
+
restart: { policy: 'on-failure', maxRestarts: 3, backoffMs: 50 },
|
|
1283
|
+
});
|
|
1284
|
+
await handle.ready;
|
|
1285
|
+
expect(domain.isResourceLocked(resource)).toBe(true);
|
|
1286
|
+
const op = domain.getStore().getOperation('service-c52#1');
|
|
1287
|
+
const childPid = op?.processIdentity?.pid;
|
|
1288
|
+
expect(childPid).toBeDefined();
|
|
1289
|
+
// 模拟宿主崩溃:关闭 domain,但不主动停止 service 实例
|
|
1290
|
+
domain.close();
|
|
1291
|
+
// 新宿主 acquire 并执行恢复
|
|
1292
|
+
const restartedDomain = ExecutionDomain.acquire(tempDir, domain.domainId);
|
|
1293
|
+
expect(restartedDomain.isResourceLocked(resource)).toBe(true);
|
|
1294
|
+
const recoveryEngine = new RecoveryEngine(restartedDomain, driver);
|
|
1295
|
+
const report = await recoveryEngine.recover();
|
|
1296
|
+
expect(report.recoveredServices).toBeDefined();
|
|
1297
|
+
const srvReport = report.recoveredServices.find((s) => s.serviceId === 'service-c52');
|
|
1298
|
+
expect(srvReport).toBeDefined();
|
|
1299
|
+
expect(srvReport?.resourcesReleased).toBe(true);
|
|
1300
|
+
expect(srvReport?.instanceOpIds).toContain('service-c52#1');
|
|
1301
|
+
// 资源租约已完全释放
|
|
1302
|
+
expect(restartedDomain.isResourceLocked(resource)).toBe(false);
|
|
1303
|
+
// 进程已停止
|
|
1304
|
+
let isAlive = true;
|
|
1305
|
+
try {
|
|
1306
|
+
process.kill(childPid, 0);
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
if (err.code === 'ESRCH')
|
|
1310
|
+
isAlive = false;
|
|
1311
|
+
}
|
|
1312
|
+
expect(isAlive).toBe(false);
|
|
1313
|
+
// 不会自动重启新实例
|
|
1314
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1315
|
+
const allOps = restartedDomain.getStore().getOperationsByRun(runId);
|
|
1316
|
+
expect(allOps.find((o) => o.id === 'service-c52#2')).toBeUndefined();
|
|
1317
|
+
restartedDomain.close();
|
|
1318
|
+
});
|
|
1319
|
+
it('契约 28: 回滚后指纹核验:restored / partial / failed 如实区分', async () => {
|
|
1320
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1321
|
+
const repoDir = path.join(tempDir, 'repo-c28');
|
|
1322
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1323
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1324
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1325
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1326
|
+
fs.writeFileSync(path.join(repoDir, 'main.ts'), 'version 1;\n');
|
|
1327
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1328
|
+
await execFileAsync('git', ['commit', '-m', 'v1'], { cwd: repoDir });
|
|
1329
|
+
ensureTaskAndRun(domain, 'task-c28', 'run-c28');
|
|
1330
|
+
// 捕获快照
|
|
1331
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1332
|
+
runId: 'run-c28',
|
|
1333
|
+
opId: 'op-c28-snap',
|
|
1334
|
+
roots: [repoDir],
|
|
1335
|
+
});
|
|
1336
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1337
|
+
const snapId = snapRes.snapshot.id;
|
|
1338
|
+
// 修改工作区文件并增加新文件
|
|
1339
|
+
fs.writeFileSync(path.join(repoDir, 'main.ts'), 'version 2 modified;\n');
|
|
1340
|
+
fs.writeFileSync(path.join(repoDir, 'extra.txt'), 'extra untracked\n');
|
|
1341
|
+
// 执行回滚
|
|
1342
|
+
const rbRes = await supervisor.rollback({
|
|
1343
|
+
runId: 'run-c28',
|
|
1344
|
+
opId: 'op-c28-rollback-1',
|
|
1345
|
+
snapshotId: snapId,
|
|
1346
|
+
});
|
|
1347
|
+
expect(rbRes.status).toBe('restored');
|
|
1348
|
+
expect(fs.readFileSync(path.join(repoDir, 'main.ts'), 'utf8')).toBe('version 1;\n');
|
|
1349
|
+
expect(fs.existsSync(path.join(repoDir, 'extra.txt'))).toBe(false);
|
|
1350
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(repoDir)}`)).toBe(false);
|
|
1351
|
+
});
|
|
1352
|
+
it('契约 29: 无写入限制时回滚 coverage 不得为 complete,outOfScopeEffects = "possible"', async () => {
|
|
1353
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1354
|
+
const repoDir = path.join(tempDir, 'repo-c29');
|
|
1355
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1356
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1357
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1358
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1359
|
+
fs.writeFileSync(path.join(repoDir, 'code.js'), 'let x = 1;\n');
|
|
1360
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1361
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1362
|
+
ensureTaskAndRun(domain, 'task-c29', 'run-c29');
|
|
1363
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1364
|
+
runId: 'run-c29',
|
|
1365
|
+
opId: 'op-c29-snap',
|
|
1366
|
+
roots: [repoDir],
|
|
1367
|
+
});
|
|
1368
|
+
const snapId = snapRes.snapshot.id;
|
|
1369
|
+
// 执行一个普通不受限的 process 操作
|
|
1370
|
+
await supervisor.executeProcess({
|
|
1371
|
+
runId: 'run-c29',
|
|
1372
|
+
opId: 'op-c29-unconfined',
|
|
1373
|
+
name: 'unconfined-cmd',
|
|
1374
|
+
command: { execPath: process.execPath, args: ['-e', 'console.log(123)'], cwd: repoDir },
|
|
1375
|
+
requiredResources: [],
|
|
1376
|
+
});
|
|
1377
|
+
// 回滚
|
|
1378
|
+
const rbRes = await supervisor.rollback({
|
|
1379
|
+
runId: 'run-c29',
|
|
1380
|
+
opId: 'op-c29-rollback',
|
|
1381
|
+
snapshotId: snapId,
|
|
1382
|
+
});
|
|
1383
|
+
// 契约 #29 严禁在无写入限制时声明 complete
|
|
1384
|
+
expect(rbRes.coverage).not.toBe('complete');
|
|
1385
|
+
expect(rbRes.coverage).toBe('declared_roots');
|
|
1386
|
+
expect(rbRes.outOfScopeEffects).toBe('possible');
|
|
1387
|
+
});
|
|
1388
|
+
it('契约 30: 回滚与目标根目录上的活跃 / 未裁决操作互斥', async () => {
|
|
1389
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1390
|
+
const repoDir = path.join(tempDir, 'repo-c30');
|
|
1391
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1392
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1393
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1394
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1395
|
+
fs.writeFileSync(path.join(repoDir, 'index.js'), '// initial\n');
|
|
1396
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1397
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1398
|
+
ensureTaskAndRun(domain, 'task-c30', 'run-c30');
|
|
1399
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1400
|
+
runId: 'run-c30',
|
|
1401
|
+
opId: 'op-c30-snap',
|
|
1402
|
+
roots: [repoDir],
|
|
1403
|
+
});
|
|
1404
|
+
const snapId = snapRes.snapshot.id;
|
|
1405
|
+
// 在目标根上持有写租约并启动长时间操作
|
|
1406
|
+
const writeRes = `workspace:write:${path.resolve(repoDir)}`;
|
|
1407
|
+
const activePromise = supervisor.executeProcess({
|
|
1408
|
+
runId: 'run-c30',
|
|
1409
|
+
opId: 'op-c30-holding',
|
|
1410
|
+
name: 'holder',
|
|
1411
|
+
command: { execPath: process.execPath, args: ['-e', 'setTimeout(() => {}, 2000)'], cwd: repoDir },
|
|
1412
|
+
requiredResources: [writeRes],
|
|
1413
|
+
});
|
|
1414
|
+
// 等待进程启动获得租约
|
|
1415
|
+
const normRes = normalizeResourceName(writeRes);
|
|
1416
|
+
while (!domain.isResourceLocked(normRes)) {
|
|
1417
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1418
|
+
}
|
|
1419
|
+
// 尝试在活跃操作持有目标目录时回滚,必须被拒绝并给出持有者诊断
|
|
1420
|
+
await expect(supervisor.rollback({
|
|
1421
|
+
runId: 'run-c30',
|
|
1422
|
+
opId: 'op-c30-rollback-conflict',
|
|
1423
|
+
snapshotId: snapId,
|
|
1424
|
+
})).rejects.toThrow(ResourceConflictError);
|
|
1425
|
+
await supervisor.cancelOperation('op-c30-holding');
|
|
1426
|
+
await activePromise;
|
|
1427
|
+
});
|
|
1428
|
+
it('契约 31: 回滚中途崩溃:恢复按指纹判定 restored 或 indeterminate', async () => {
|
|
1429
|
+
const { supervisor, domain, driver, tempDir } = ctx;
|
|
1430
|
+
const repoDir = path.join(tempDir, 'repo-c31');
|
|
1431
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1432
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1433
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1434
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1435
|
+
fs.writeFileSync(path.join(repoDir, 'tracked.txt'), 'clean v1\n');
|
|
1436
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1437
|
+
await execFileAsync('git', ['commit', '-m', 'clean v1'], { cwd: repoDir });
|
|
1438
|
+
ensureTaskAndRun(domain, 'task-c31', 'run-c31');
|
|
1439
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1440
|
+
runId: 'run-c31',
|
|
1441
|
+
opId: 'op-c31-snap',
|
|
1442
|
+
roots: [repoDir],
|
|
1443
|
+
});
|
|
1444
|
+
const snapId = snapRes.snapshot.id;
|
|
1445
|
+
// 场景 A: 模拟回滚中途宿主崩溃,但工作区指纹与快照一致
|
|
1446
|
+
const writeRes = `workspace:write:${path.resolve(repoDir)}`;
|
|
1447
|
+
domain.getStore().registerOperationIntent({
|
|
1448
|
+
id: 'op-c31-crash-match',
|
|
1449
|
+
runId: 'run-c31',
|
|
1450
|
+
kind: 'rollback',
|
|
1451
|
+
name: `rollback:${snapId}`,
|
|
1452
|
+
inputFingerprint: 'fp-c31',
|
|
1453
|
+
requiredResources: [writeRes],
|
|
1454
|
+
mutationRoots: [path.resolve(repoDir)],
|
|
1455
|
+
outputRef: snapId,
|
|
1456
|
+
status: 'intent_registered',
|
|
1457
|
+
}, domain.domainId);
|
|
1458
|
+
domain.getStore().updateOperationStatus('op-c31-crash-match', 'active');
|
|
1459
|
+
domain.allocateResources('op-c31-crash-match', [writeRes]);
|
|
1460
|
+
const recoveryA = new RecoveryEngine(domain, driver);
|
|
1461
|
+
const repA = await recoveryA.recover();
|
|
1462
|
+
const recA = repA.recoveredOperations.find((o) => o.opId === 'op-c31-crash-match');
|
|
1463
|
+
expect(recA?.resourcesReleased).toBe(true);
|
|
1464
|
+
const opA = domain.getStore().getOperation('op-c31-crash-match');
|
|
1465
|
+
expect(opA?.result?.status).toBe('restored');
|
|
1466
|
+
expect(domain.isResourceLocked(writeRes)).toBe(false);
|
|
1467
|
+
// 场景 B: 模拟回滚中途宿主崩溃,且工作区被修改导致指纹不匹配
|
|
1468
|
+
ensureTaskAndRun(domain, 'task-c31', 'run-c31-b');
|
|
1469
|
+
fs.writeFileSync(path.join(repoDir, 'tracked.txt'), 'corrupted mid-rollback\n');
|
|
1470
|
+
domain.getStore().registerOperationIntent({
|
|
1471
|
+
id: 'op-c31-crash-mismatch',
|
|
1472
|
+
runId: 'run-c31-b',
|
|
1473
|
+
kind: 'rollback',
|
|
1474
|
+
name: `rollback:${snapId}`,
|
|
1475
|
+
inputFingerprint: 'fp-c31-b',
|
|
1476
|
+
requiredResources: [writeRes],
|
|
1477
|
+
mutationRoots: [path.resolve(repoDir)],
|
|
1478
|
+
outputRef: snapId,
|
|
1479
|
+
status: 'intent_registered',
|
|
1480
|
+
}, domain.domainId);
|
|
1481
|
+
domain.getStore().updateOperationStatus('op-c31-crash-mismatch', 'active');
|
|
1482
|
+
domain.allocateResources('op-c31-crash-mismatch', [writeRes]);
|
|
1483
|
+
const repB = await recoveryA.recover();
|
|
1484
|
+
const recB = repB.recoveredOperations.find((o) => o.opId === 'op-c31-crash-mismatch');
|
|
1485
|
+
expect(recB?.resourcesReleased).toBe(false);
|
|
1486
|
+
const opB = domain.getStore().getOperation('op-c31-crash-mismatch');
|
|
1487
|
+
expect(opB?.result?.status).toBe('indeterminate');
|
|
1488
|
+
expect(domain.isResourceLocked(writeRes)).toBe(true);
|
|
1489
|
+
// 清理资源
|
|
1490
|
+
domain.internalReleaseResources('op-c31-crash-mismatch', [writeRes]);
|
|
1491
|
+
});
|
|
1492
|
+
it('契约 32: git-shadow 快照不改动用户 index、HEAD 与分支,并如实声明 worktree_non_ignored', async () => {
|
|
1493
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1494
|
+
const repoDir = path.join(tempDir, 'repo-c32');
|
|
1495
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1496
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1497
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1498
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1499
|
+
fs.writeFileSync(path.join(repoDir, 'base.txt'), 'base\n');
|
|
1500
|
+
fs.writeFileSync(path.join(repoDir, '.gitignore'), '.env\n');
|
|
1501
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1502
|
+
await execFileAsync('git', ['commit', '-m', 'base commit'], { cwd: repoDir });
|
|
1503
|
+
// 构造用户正在工作的工作区现场
|
|
1504
|
+
fs.writeFileSync(path.join(repoDir, 'staged.txt'), 'user staged change\n');
|
|
1505
|
+
await execFileAsync('git', ['add', 'staged.txt'], { cwd: repoDir });
|
|
1506
|
+
fs.writeFileSync(path.join(repoDir, 'unstaged.txt'), 'user unstaged change\n');
|
|
1507
|
+
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=donttouch\n');
|
|
1508
|
+
const statusBefore = (await execFileAsync('git', ['status', '--porcelain'], { cwd: repoDir })).stdout;
|
|
1509
|
+
const stageBefore = (await execFileAsync('git', ['ls-files', '--stage'], { cwd: repoDir })).stdout;
|
|
1510
|
+
const headBefore = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).stdout;
|
|
1511
|
+
const branchBefore = (await execFileAsync('git', ['branch'], { cwd: repoDir })).stdout;
|
|
1512
|
+
ensureTaskAndRun(domain, 'task-c32', 'run-c32');
|
|
1513
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1514
|
+
runId: 'run-c32',
|
|
1515
|
+
opId: 'op-c32',
|
|
1516
|
+
roots: [repoDir],
|
|
1517
|
+
});
|
|
1518
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1519
|
+
expect(snapRes.snapshot?.coverage).toBe('worktree_non_ignored');
|
|
1520
|
+
const statusAfter = (await execFileAsync('git', ['status', '--porcelain'], { cwd: repoDir })).stdout;
|
|
1521
|
+
const stageAfter = (await execFileAsync('git', ['ls-files', '--stage'], { cwd: repoDir })).stdout;
|
|
1522
|
+
const headAfter = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoDir })).stdout;
|
|
1523
|
+
const branchAfter = (await execFileAsync('git', ['branch'], { cwd: repoDir })).stdout;
|
|
1524
|
+
expect(statusAfter).toBe(statusBefore);
|
|
1525
|
+
expect(stageAfter).toBe(stageBefore);
|
|
1526
|
+
expect(headAfter).toBe(headBefore);
|
|
1527
|
+
expect(branchAfter).toBe(branchBefore);
|
|
1528
|
+
});
|
|
1529
|
+
it('契约 53: SnapshotRef.journalSeq 与捕获事件 seq 一致;可按 seq 查询不晚于它的最近快照', async () => {
|
|
1530
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1531
|
+
const repoDir = path.join(tempDir, 'repo-c53');
|
|
1532
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1533
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1534
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1535
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1536
|
+
fs.writeFileSync(path.join(repoDir, 'step.txt'), 'step 0\n');
|
|
1537
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1538
|
+
await execFileAsync('git', ['commit', '-m', 'step 0'], { cwd: repoDir });
|
|
1539
|
+
ensureTaskAndRun(domain, 'task-c53', 'run-c53');
|
|
1540
|
+
// 捕获第 1 个快照
|
|
1541
|
+
const snap1 = await supervisor.captureSnapshot({
|
|
1542
|
+
runId: 'run-c53',
|
|
1543
|
+
opId: 'snap-1',
|
|
1544
|
+
roots: [repoDir],
|
|
1545
|
+
});
|
|
1546
|
+
const events1 = domain.getStore().getJournalEvents(domain.domainId);
|
|
1547
|
+
const capEvent1 = events1.find((e) => e.type === 'SNAPSHOT_CAPTURED' && e.payload?.snapshotId === 'snap-1');
|
|
1548
|
+
expect(capEvent1).toBeDefined();
|
|
1549
|
+
expect(snap1.snapshot?.journalSeq).toBe(capEvent1?.seq);
|
|
1550
|
+
fs.writeFileSync(path.join(repoDir, 'step.txt'), 'step 1\n');
|
|
1551
|
+
// 捕获第 2 个快照
|
|
1552
|
+
const snap2 = await supervisor.captureSnapshot({
|
|
1553
|
+
runId: 'run-c53',
|
|
1554
|
+
opId: 'snap-2',
|
|
1555
|
+
roots: [repoDir],
|
|
1556
|
+
});
|
|
1557
|
+
const events2 = domain.getStore().getJournalEvents(domain.domainId);
|
|
1558
|
+
const capEvent2 = events2.find((e) => e.type === 'SNAPSHOT_CAPTURED' && e.payload?.snapshotId === 'snap-2');
|
|
1559
|
+
expect(snap2.snapshot?.journalSeq).toBe(capEvent2?.seq);
|
|
1560
|
+
// 查询 seq 不晚于 capEvent1.seq 的快照 => 必须返回 snap-1
|
|
1561
|
+
const foundSnap1 = domain.getStore().findSnapshotAtOrBefore(domain.domainId, capEvent1.seq);
|
|
1562
|
+
expect(foundSnap1?.id).toBe('snap-1');
|
|
1563
|
+
// 查询 seq 不晚于 capEvent2.seq 的快照 => 必须返回 snap-2
|
|
1564
|
+
const foundSnap2 = domain.getStore().findSnapshotAtOrBefore(domain.domainId, capEvent2.seq);
|
|
1565
|
+
expect(foundSnap2?.id).toBe('snap-2');
|
|
1566
|
+
});
|
|
1567
|
+
it('契约 54: materialize 生成的工作区独立;源工作区、用户 index、HEAD 与分支不变;dematerialize 后无残留 worktree', async () => {
|
|
1568
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1569
|
+
const repoDir = path.join(tempDir, 'repo-c54');
|
|
1570
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1571
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1572
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1573
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1574
|
+
fs.writeFileSync(path.join(repoDir, 'entry.ts'), 'export const val = 42;\n');
|
|
1575
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1576
|
+
await execFileAsync('git', ['commit', '-m', 'entry commit'], { cwd: repoDir });
|
|
1577
|
+
ensureTaskAndRun(domain, 'task-c54', 'run-c54');
|
|
1578
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1579
|
+
runId: 'run-c54',
|
|
1580
|
+
opId: 'snap-c54',
|
|
1581
|
+
roots: [repoDir],
|
|
1582
|
+
});
|
|
1583
|
+
const forkDir = path.join(tempDir, 'fork-c54');
|
|
1584
|
+
const { worktreePath } = await supervisor.materialize('snap-c54', forkDir);
|
|
1585
|
+
expect(worktreePath).toBe(forkDir);
|
|
1586
|
+
expect(fs.existsSync(path.join(forkDir, 'entry.ts'))).toBe(true);
|
|
1587
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(forkDir)}`)).toBe(true);
|
|
1588
|
+
// dematerialize
|
|
1589
|
+
await supervisor.dematerialize(forkDir);
|
|
1590
|
+
expect(fs.existsSync(forkDir)).toBe(false);
|
|
1591
|
+
expect(domain.isResourceLocked(`workspace:write:${path.resolve(forkDir)}`)).toBe(false);
|
|
1592
|
+
const { stdout: wtList } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoDir });
|
|
1593
|
+
expect(wtList).not.toContain(forkDir);
|
|
1594
|
+
});
|
|
1595
|
+
it('契约 55: 准入校验:过期 / 撤销 / 旧 epoch / 越界资源 / 越界路径抛 CapabilityViolationError 并记 CAPABILITY_REJECTED;缺省推导生效并记 CAPABILITY_USED', async () => {
|
|
1596
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1597
|
+
ensureTaskAndRun(domain, 'task-c55', 'run-c55');
|
|
1598
|
+
const allowedDir = path.join(tempDir, 'allowed');
|
|
1599
|
+
const outsideDir = path.join(tempDir, 'outside');
|
|
1600
|
+
fs.mkdirSync(allowedDir, { recursive: true });
|
|
1601
|
+
fs.mkdirSync(outsideDir, { recursive: true });
|
|
1602
|
+
// 1. 签发有效 capability
|
|
1603
|
+
const cap = domain.issueCapability({
|
|
1604
|
+
write: [allowedDir],
|
|
1605
|
+
exclusive: ['res:allowed-1'],
|
|
1606
|
+
}, 'tester', 60000);
|
|
1607
|
+
// 2. 越界资源
|
|
1608
|
+
await expect(supervisor.executeProcess({
|
|
1609
|
+
runId: 'run-c55',
|
|
1610
|
+
opId: 'op-c55-res-violation',
|
|
1611
|
+
name: 'violating-proc',
|
|
1612
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: allowedDir },
|
|
1613
|
+
requiredResources: ['res:unauthorized-res'],
|
|
1614
|
+
capabilityId: cap.id,
|
|
1615
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1616
|
+
// 3. 越界路径 (cwd 在 allowedDir 之外)
|
|
1617
|
+
await expect(supervisor.executeProcess({
|
|
1618
|
+
runId: 'run-c55',
|
|
1619
|
+
opId: 'op-c55-path-violation',
|
|
1620
|
+
name: 'violating-path',
|
|
1621
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: outsideDir },
|
|
1622
|
+
capabilityId: cap.id,
|
|
1623
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1624
|
+
// 4. 撤销 capability
|
|
1625
|
+
domain.revokeCapability(cap.id, 'test_revocation');
|
|
1626
|
+
await expect(supervisor.executeProcess({
|
|
1627
|
+
runId: 'run-c55',
|
|
1628
|
+
opId: 'op-c55-revoked',
|
|
1629
|
+
name: 'revoked-proc',
|
|
1630
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: allowedDir },
|
|
1631
|
+
capabilityId: cap.id,
|
|
1632
|
+
})).rejects.toThrowError(CapabilityViolationError);
|
|
1633
|
+
// 验证 CAPABILITY_REJECTED 事件已入 journal
|
|
1634
|
+
const events = domain.getStore().getJournalEvents(domain.domainId);
|
|
1635
|
+
const rejectedEvents = events.filter((e) => e.type === 'CAPABILITY_REJECTED');
|
|
1636
|
+
expect(rejectedEvents.length).toBeGreaterThanOrEqual(3);
|
|
1637
|
+
// 5. 缺省 capability 推导生效与 CAPABILITY_USED 记录
|
|
1638
|
+
const validCap = domain.issueCapability({
|
|
1639
|
+
write: [tempDir],
|
|
1640
|
+
exclusive: ['res:default-1'],
|
|
1641
|
+
}, 'tester', 60000);
|
|
1642
|
+
const res = await supervisor.executeProcess({
|
|
1643
|
+
runId: 'run-c55',
|
|
1644
|
+
opId: 'op-c55-success',
|
|
1645
|
+
name: 'valid-proc',
|
|
1646
|
+
command: { execPath: process.execPath, args: ['-e', 'process.exit(0)'], cwd: tempDir },
|
|
1647
|
+
requiredResources: ['res:default-1'],
|
|
1648
|
+
capabilityId: validCap.id,
|
|
1649
|
+
});
|
|
1650
|
+
expect(res.status).toBe('succeeded');
|
|
1651
|
+
expect(res.capabilityId).toBe(validCap.id);
|
|
1652
|
+
const usedEvents = domain.getStore().getJournalEvents(domain.domainId).filter((e) => e.type === 'CAPABILITY_USED');
|
|
1653
|
+
expect(usedEvents.some((e) => e.payload?.capabilityId === validCap.id)).toBe(true);
|
|
1654
|
+
});
|
|
1655
|
+
it('契约 56: 写入限制驱动:受限写外部路径失败;快照后全受限回滚声明 complete,混入不受限声明 declared_roots', async () => {
|
|
1656
|
+
const { supervisor, domain, tempDir } = ctx;
|
|
1657
|
+
const repoDir = path.join(tempDir, 'repo-c56');
|
|
1658
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
1659
|
+
await execFileAsync('git', ['init', '-b', 'main'], { cwd: repoDir });
|
|
1660
|
+
await execFileAsync('git', ['config', 'user.name', 'Tester'], { cwd: repoDir });
|
|
1661
|
+
await execFileAsync('git', ['config', 'user.email', 'tester@test.local'], { cwd: repoDir });
|
|
1662
|
+
fs.writeFileSync(path.join(repoDir, 'init.txt'), 'init\n');
|
|
1663
|
+
await execFileAsync('git', ['add', '.'], { cwd: repoDir });
|
|
1664
|
+
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repoDir });
|
|
1665
|
+
ensureTaskAndRun(domain, 'task-c56', 'run-c56');
|
|
1666
|
+
const mockDriver = {
|
|
1667
|
+
name: 'mock-confinement',
|
|
1668
|
+
wrap: (cmd) => cmd,
|
|
1669
|
+
};
|
|
1670
|
+
// 1. 签发受限 capability (针对 repoDir)
|
|
1671
|
+
const confinedCap = domain.issueCapability({
|
|
1672
|
+
write: [repoDir],
|
|
1673
|
+
exclusive: [`workspace:write:${path.resolve(repoDir)}`],
|
|
1674
|
+
}, 'tester', 60000);
|
|
1675
|
+
// 捕获快照
|
|
1676
|
+
const snapRes = await supervisor.captureSnapshot({
|
|
1677
|
+
runId: 'run-c56',
|
|
1678
|
+
opId: 'snap-c56',
|
|
1679
|
+
capabilityId: confinedCap.id,
|
|
1680
|
+
});
|
|
1681
|
+
expect(snapRes.status).toBe('succeeded');
|
|
1682
|
+
// 在受限下运行进程写文件
|
|
1683
|
+
const p1 = await supervisor.executeProcess({
|
|
1684
|
+
runId: 'run-c56',
|
|
1685
|
+
opId: 'op-c56-confined',
|
|
1686
|
+
name: 'write-in-repo',
|
|
1687
|
+
command: {
|
|
1688
|
+
execPath: process.execPath,
|
|
1689
|
+
args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(path.join(repoDir, 'a.txt'))}, 'a');`],
|
|
1690
|
+
cwd: repoDir,
|
|
1691
|
+
},
|
|
1692
|
+
capabilityId: confinedCap.id,
|
|
1693
|
+
confinementDriver: mockDriver,
|
|
1694
|
+
});
|
|
1695
|
+
expect(p1.status).toBe('succeeded');
|
|
1696
|
+
expect(p1.confined).toBe(true);
|
|
1697
|
+
// 全部 op 受限,回滚 coverage 必须为 complete
|
|
1698
|
+
const rollback1 = await supervisor.rollback({
|
|
1699
|
+
runId: 'run-c56',
|
|
1700
|
+
opId: 'rb-c56-1',
|
|
1701
|
+
snapshotId: 'snap-c56',
|
|
1702
|
+
capabilityId: confinedCap.id,
|
|
1703
|
+
});
|
|
1704
|
+
expect(rollback1.coverage).toBe('complete');
|
|
1705
|
+
expect(rollback1.outOfScopeEffects).toBe('none_possible');
|
|
1706
|
+
// 2. 混入不受限操作
|
|
1707
|
+
ensureTaskAndRun(domain, 'task-c56', 'run-c56-unconfined');
|
|
1708
|
+
const snap2 = await supervisor.captureSnapshot({
|
|
1709
|
+
runId: 'run-c56-unconfined',
|
|
1710
|
+
opId: 'snap-c56-2',
|
|
1711
|
+
roots: [repoDir],
|
|
1712
|
+
});
|
|
1713
|
+
const p2 = await supervisor.executeProcess({
|
|
1714
|
+
runId: 'run-c56-unconfined',
|
|
1715
|
+
opId: 'op-c56-unconfined',
|
|
1716
|
+
name: 'write-unconfined',
|
|
1717
|
+
command: {
|
|
1718
|
+
execPath: process.execPath,
|
|
1719
|
+
args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(path.join(repoDir, 'b.txt'))}, 'b');`],
|
|
1720
|
+
cwd: repoDir,
|
|
1721
|
+
},
|
|
1722
|
+
requiredResources: [`workspace:write:${path.resolve(repoDir)}`],
|
|
1723
|
+
});
|
|
1724
|
+
expect(p2.confined).toBeFalsy();
|
|
1725
|
+
// 混入不受限操作,回滚 coverage 退化为 declared_roots
|
|
1726
|
+
const rollback2 = await supervisor.rollback({
|
|
1727
|
+
runId: 'run-c56-unconfined',
|
|
1728
|
+
opId: 'rb-c56-2',
|
|
1729
|
+
snapshotId: 'snap-c56-2',
|
|
1730
|
+
});
|
|
1731
|
+
expect(rollback2.coverage).toBe('declared_roots');
|
|
1732
|
+
expect(rollback2.outOfScopeEffects).toBe('possible');
|
|
1733
|
+
});
|
|
988
1734
|
});
|
|
989
1735
|
}
|
|
990
1736
|
//# sourceMappingURL=contract-suite.js.map
|