@xioflow/kernel 0.3.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.
Files changed (85) hide show
  1. package/README.md +80 -13
  2. package/dist/capability/check.d.ts +32 -0
  3. package/dist/capability/check.d.ts.map +1 -0
  4. package/dist/capability/check.js +136 -0
  5. package/dist/capability/check.js.map +1 -0
  6. package/dist/capability/index.d.ts +3 -0
  7. package/dist/capability/index.d.ts.map +1 -0
  8. package/dist/capability/index.js +3 -0
  9. package/dist/capability/index.js.map +1 -0
  10. package/dist/capability/path-utils.d.ts +11 -0
  11. package/dist/capability/path-utils.d.ts.map +1 -0
  12. package/dist/capability/path-utils.js +46 -0
  13. package/dist/capability/path-utils.js.map +1 -0
  14. package/dist/confinement/bubblewrap.d.ts +8 -0
  15. package/dist/confinement/bubblewrap.d.ts.map +1 -0
  16. package/dist/confinement/bubblewrap.js +45 -0
  17. package/dist/confinement/bubblewrap.js.map +1 -0
  18. package/dist/confinement/detector.d.ts +4 -0
  19. package/dist/confinement/detector.d.ts.map +1 -0
  20. package/dist/confinement/detector.js +35 -0
  21. package/dist/confinement/detector.js.map +1 -0
  22. package/dist/confinement/index.d.ts +5 -0
  23. package/dist/confinement/index.d.ts.map +1 -0
  24. package/dist/confinement/index.js +5 -0
  25. package/dist/confinement/index.js.map +1 -0
  26. package/dist/confinement/sandbox-exec.d.ts +8 -0
  27. package/dist/confinement/sandbox-exec.d.ts.map +1 -0
  28. package/dist/confinement/sandbox-exec.js +30 -0
  29. package/dist/confinement/sandbox-exec.js.map +1 -0
  30. package/dist/confinement/srt.d.ts +8 -0
  31. package/dist/confinement/srt.d.ts.map +1 -0
  32. package/dist/confinement/srt.js +41 -0
  33. package/dist/confinement/srt.js.map +1 -0
  34. package/dist/domain.d.ts +26 -2
  35. package/dist/domain.d.ts.map +1 -1
  36. package/dist/domain.js +192 -18
  37. package/dist/domain.js.map +1 -1
  38. package/dist/driver/node-driver.d.ts.map +1 -1
  39. package/dist/driver/node-driver.js +46 -36
  40. package/dist/driver/node-driver.js.map +1 -1
  41. package/dist/driver/types.d.ts +13 -0
  42. package/dist/driver/types.d.ts.map +1 -1
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/quick-run.d.ts.map +1 -1
  48. package/dist/quick-run.js +5 -12
  49. package/dist/quick-run.js.map +1 -1
  50. package/dist/recovery/engine.d.ts +7 -0
  51. package/dist/recovery/engine.d.ts.map +1 -1
  52. package/dist/recovery/engine.js +174 -4
  53. package/dist/recovery/engine.js.map +1 -1
  54. package/dist/snapshot/git-shadow.d.ts +49 -0
  55. package/dist/snapshot/git-shadow.d.ts.map +1 -0
  56. package/dist/snapshot/git-shadow.js +359 -0
  57. package/dist/snapshot/git-shadow.js.map +1 -0
  58. package/dist/store/schema.d.ts +1 -1
  59. package/dist/store/schema.d.ts.map +1 -1
  60. package/dist/store/schema.js +31 -0
  61. package/dist/store/schema.js.map +1 -1
  62. package/dist/store/sqlite.d.ts +14 -1
  63. package/dist/store/sqlite.d.ts.map +1 -1
  64. package/dist/store/sqlite.js +145 -58
  65. package/dist/store/sqlite.js.map +1 -1
  66. package/dist/supervisor/drainer.d.ts +17 -0
  67. package/dist/supervisor/drainer.d.ts.map +1 -0
  68. package/dist/supervisor/drainer.js +200 -0
  69. package/dist/supervisor/drainer.js.map +1 -0
  70. package/dist/supervisor/service.d.ts +24 -0
  71. package/dist/supervisor/service.d.ts.map +1 -0
  72. package/dist/supervisor/service.js +588 -0
  73. package/dist/supervisor/service.js.map +1 -0
  74. package/dist/supervisor/supervisor.d.ts +61 -4
  75. package/dist/supervisor/supervisor.d.ts.map +1 -1
  76. package/dist/supervisor/supervisor.js +572 -204
  77. package/dist/supervisor/supervisor.js.map +1 -1
  78. package/dist/testing/contract-suite.d.ts.map +1 -1
  79. package/dist/testing/contract-suite.js +570 -2
  80. package/dist/testing/contract-suite.js.map +1 -1
  81. package/dist/types.d.ts +155 -2
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js +8 -0
  84. package/dist/types.js.map +1 -1
  85. package/package.json +1 -1
@@ -1,7 +1,25 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { UnsupportedCapabilityError, OperationNotActiveError, OperationIdConflictError, RecoveryRequiredError, } from '../types.js';
4
+ import { UnsupportedCapabilityError, OperationNotActiveError, OperationIdConflictError, RecoveryRequiredError, ResourceConflictError, } from '../types.js';
5
+ import { NodePlatformDriver } from '../driver/node-driver.js';
6
+ import { GitShadowSnapshotDriver } from '../snapshot/git-shadow.js';
7
+ import { ServiceSupervisor } from './service.js';
8
+ import { setupStreamDrainer } from './drainer.js';
9
+ import { isPathContained } from '../capability/index.js';
10
+ import { createConfinementDriver } from '../confinement/index.js';
11
+ export function normalizeResourceName(res) {
12
+ if (res.startsWith('workspace:write:')) {
13
+ const raw = res.slice('workspace:write:'.length);
14
+ try {
15
+ return `workspace:write:${fs.realpathSync(raw)}`;
16
+ }
17
+ catch {
18
+ return `workspace:write:${path.resolve(raw)}`;
19
+ }
20
+ }
21
+ return res;
22
+ }
5
23
  /**
6
24
  * 规范化计算完整输入的 SHA-256 指纹 (ARCHITECTURE §2 / P0-12)
7
25
  * 覆盖 execPath, args, cwd, 键排序的 envWhiteList, inheritEnv, sha256(stdin), 排序后的 requiredResources, timeoutMs, resourceBudget.
@@ -35,42 +53,26 @@ export function computeInputFingerprint(options) {
35
53
  : null,
36
54
  stdinHash,
37
55
  timeoutMs: options.timeoutMs ?? null,
56
+ capabilityId: options.capabilityId ?? null,
38
57
  };
39
58
  const canonicalJson = JSON.stringify(canonical);
40
59
  return crypto.createHash('sha256').update(canonicalJson).digest('hex');
41
60
  }
42
- function trimToValidUtf8(buf) {
43
- const len = buf.length;
44
- for (let i = 1; i <= Math.min(3, len); i++) {
45
- const b = buf[len - i];
46
- if ((b & 0xc0) === 0xc0) {
47
- const needed = (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 1;
48
- if (i < needed) {
49
- return Buffer.from(buf.subarray(0, len - i));
50
- }
51
- break;
52
- }
53
- else if ((b & 0x80) === 0) {
54
- break;
55
- }
56
- }
57
- return buf;
58
- }
59
- function trimStartToValidUtf8(buf) {
60
- let start = 0;
61
- while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
62
- start++;
63
- }
64
- return Buffer.from(buf.subarray(start));
65
- }
66
61
  export class ProcessSupervisor {
67
62
  domain;
68
63
  driver;
69
64
  activeOperations = new Map();
70
- constructor(domain, driver) {
65
+ serviceSupervisor;
66
+ snapshotDriver;
67
+ confinementDriver;
68
+ constructor(domain, driver = domain.getDriver?.() || new NodePlatformDriver(), snapshotDriver, confinementDriver) {
71
69
  this.domain = domain;
72
70
  this.driver = driver;
73
71
  this.domain.setDriver?.(driver);
72
+ this.serviceSupervisor = new ServiceSupervisor(domain, driver, this);
73
+ this.snapshotDriver = snapshotDriver || new GitShadowSnapshotDriver();
74
+ this.confinementDriver = confinementDriver;
75
+ this.domain.setSnapshotDriver?.(this.snapshotDriver);
74
76
  }
75
77
  getDomain() {
76
78
  return this.domain;
@@ -78,10 +80,509 @@ export class ProcessSupervisor {
78
80
  getDriver() {
79
81
  return this.driver;
80
82
  }
83
+ getSnapshotDriver() {
84
+ return this.snapshotDriver;
85
+ }
86
+ setConfinementDriver(driver) {
87
+ this.confinementDriver = driver;
88
+ }
89
+ getConfinementDriver() {
90
+ return this.confinementDriver;
91
+ }
92
+ async startService(spec) {
93
+ return this.serviceSupervisor.startService(spec);
94
+ }
95
+ /**
96
+ * 快照捕获协议 (ARCHITECTURE §3.5 / 契约 #32, #53)
97
+ */
98
+ async captureSnapshot(options) {
99
+ const startTime = Date.now();
100
+ let targetRoots = options.roots;
101
+ let capAdmission;
102
+ if (options.capabilityId) {
103
+ capAdmission = this.domain.checkCapabilityAdmission({
104
+ capabilityId: options.capabilityId,
105
+ mutationRoots: targetRoots,
106
+ runId: options.runId,
107
+ opId: options.opId,
108
+ });
109
+ if (!targetRoots || targetRoots.length === 0) {
110
+ targetRoots = capAdmission.effectiveMutationRoots;
111
+ }
112
+ }
113
+ if (!targetRoots || targetRoots.length === 0) {
114
+ throw new Error('captureSnapshot requires at least one root directory');
115
+ }
116
+ const realRoots = targetRoots.map((r) => path.resolve(r));
117
+ const requiredResources = realRoots.map((r) => `workspace:write:${r}`);
118
+ if (this.snapshotDriver instanceof GitShadowSnapshotDriver) {
119
+ try {
120
+ const { commonDir } = await this.snapshotDriver.assertGitRepo(realRoots[0]);
121
+ requiredResources.push(`git:objects:${commonDir}`);
122
+ }
123
+ catch { }
124
+ }
125
+ const inputFingerprint = crypto
126
+ .createHash('sha256')
127
+ .update(JSON.stringify({
128
+ kind: 'snapshot',
129
+ roots: realRoots,
130
+ includeIgnored: !!options.includeIgnored,
131
+ maxTreeSizeBytes: options.maxTreeSizeBytes,
132
+ capabilityId: options.capabilityId ?? null,
133
+ }))
134
+ .digest('hex');
135
+ const op = {
136
+ id: options.opId,
137
+ runId: options.runId,
138
+ kind: 'snapshot',
139
+ name: `snapshot:${realRoots[0]}`,
140
+ inputFingerprint,
141
+ requiredResources,
142
+ mutationRoots: realRoots,
143
+ capabilityId: options.capabilityId,
144
+ timeoutMs: options.timeoutMs,
145
+ status: 'intent_registered',
146
+ };
147
+ this.domain.registerOperationIntent(op);
148
+ this.domain.getStore().updateOperationStatus(options.opId, 'active');
149
+ this.domain.getStore().recordEventAndTransitionState({
150
+ domainId: this.domain.domainId,
151
+ runId: options.runId,
152
+ operationId: options.opId,
153
+ type: 'OPERATION_STATUS_TRANSITION',
154
+ payload: { status: 'active' },
155
+ timestamp: new Date().toISOString(),
156
+ });
157
+ if (options.capabilityId && capAdmission) {
158
+ this.domain.getStore().recordJournalEvent({
159
+ domainId: this.domain.domainId,
160
+ runId: options.runId,
161
+ operationId: options.opId,
162
+ type: 'CAPABILITY_USED',
163
+ payload: {
164
+ capabilityId: options.capabilityId,
165
+ scope: capAdmission.capability.scope,
166
+ actor: capAdmission.capability.issuedBy,
167
+ },
168
+ timestamp: new Date().toISOString(),
169
+ });
170
+ }
171
+ try {
172
+ const snapRef = await this.snapshotDriver.capture(realRoots, {
173
+ id: options.opId,
174
+ domainId: this.domain.domainId,
175
+ opId: options.opId,
176
+ includeIgnored: options.includeIgnored,
177
+ maxTreeSizeBytes: options.maxTreeSizeBytes,
178
+ });
179
+ let result;
180
+ this.domain.getStore().transaction(() => {
181
+ const seq = this.domain.getStore().recordEventAndTransitionState({
182
+ domainId: this.domain.domainId,
183
+ runId: options.runId,
184
+ operationId: options.opId,
185
+ type: 'SNAPSHOT_CAPTURED',
186
+ payload: {
187
+ snapshotId: snapRef.id,
188
+ roots: snapRef.roots,
189
+ treeFingerprint: snapRef.treeFingerprint,
190
+ commitHash: snapRef.commitHash,
191
+ },
192
+ timestamp: new Date().toISOString(),
193
+ });
194
+ snapRef.journalSeq = seq;
195
+ this.domain.getStore().recordSnapshot(snapRef);
196
+ result = {
197
+ kind: 'snapshot',
198
+ status: 'succeeded',
199
+ snapshot: snapRef,
200
+ capabilityId: options.capabilityId,
201
+ durationMs: Date.now() - startTime,
202
+ completedAt: new Date().toISOString(),
203
+ };
204
+ this.domain.getStore().recordOperationResult(options.opId, result);
205
+ this.domain.getStore().updateOperationStatus(options.opId, 'done');
206
+ this.domain.getStore().recordEventAndTransitionState({
207
+ domainId: this.domain.domainId,
208
+ runId: options.runId,
209
+ operationId: options.opId,
210
+ type: 'OPERATION_RESULT_RECORDED',
211
+ payload: { status: 'succeeded', snapshotId: snapRef.id },
212
+ timestamp: new Date().toISOString(),
213
+ });
214
+ });
215
+ this.domain.internalReleaseResources(options.opId);
216
+ return result;
217
+ }
218
+ catch (err) {
219
+ const failResult = {
220
+ kind: 'snapshot',
221
+ status: 'failed',
222
+ errorMessage: err.message,
223
+ durationMs: Date.now() - startTime,
224
+ completedAt: new Date().toISOString(),
225
+ };
226
+ let recordError;
227
+ try {
228
+ this.domain.getStore().recordOperationResult(options.opId, failResult);
229
+ this.domain.getStore().updateOperationStatus(options.opId, 'done');
230
+ }
231
+ catch (recErr) {
232
+ recordError = recErr;
233
+ }
234
+ this.domain.internalReleaseResources(options.opId);
235
+ if (recordError !== undefined) {
236
+ // 失败事实没能落盘时不能只抛原始错误:调用方需要知道 journal 里没有这条结果
237
+ const recMsg = recordError instanceof Error ? recordError.message : String(recordError);
238
+ throw new Error(`${err.message} (additionally, recording the failed snapshot result failed: ${recMsg})`, {
239
+ cause: err,
240
+ });
241
+ }
242
+ throw err;
243
+ }
244
+ }
245
+ /**
246
+ * 回滚协议 (ARCHITECTURE §3.5 / 契约 #28–#30, #56)
247
+ */
248
+ async rollback(options) {
249
+ const startTime = Date.now();
250
+ const snapshot = this.domain.getStore().getSnapshot(options.snapshotId);
251
+ if (!snapshot) {
252
+ throw new Error(`Snapshot not found: ${options.snapshotId}`);
253
+ }
254
+ let targetRoots = (options.roots || snapshot.roots).map((r) => path.resolve(r));
255
+ let capAdmission;
256
+ if (options.capabilityId) {
257
+ capAdmission = this.domain.checkCapabilityAdmission({
258
+ capabilityId: options.capabilityId,
259
+ mutationRoots: targetRoots,
260
+ runId: options.runId,
261
+ opId: options.opId,
262
+ });
263
+ targetRoots = capAdmission.effectiveMutationRoots;
264
+ }
265
+ // 0. 前置条件检查(契约 #30):目标根目录上不存在 active / stopping / 未裁决 indeterminate 的操作
266
+ for (const root of targetRoots) {
267
+ let realRoot = root;
268
+ try {
269
+ realRoot = fs.realpathSync(root);
270
+ }
271
+ catch { }
272
+ const res1 = `workspace:write:${root}`;
273
+ const res2 = `workspace:write:${realRoot}`;
274
+ if (this.domain.isResourceLocked(res1)) {
275
+ throw new ResourceConflictError(res1, this.domain.getResourceOwner(res1) || 'unknown', options.opId, 0);
276
+ }
277
+ if (this.domain.isResourceLocked(res2)) {
278
+ throw new ResourceConflictError(res2, this.domain.getResourceOwner(res2) || 'unknown', options.opId, 0);
279
+ }
280
+ }
281
+ const unfinishedOps = this.domain.getStore().getUnfinishedOperations(this.domain.domainId);
282
+ for (const op of unfinishedOps) {
283
+ if (op.id === options.opId)
284
+ continue;
285
+ for (const root of targetRoots) {
286
+ let realRoot = root;
287
+ try {
288
+ realRoot = fs.realpathSync(root);
289
+ }
290
+ catch { }
291
+ const writeRes1 = `workspace:write:${root}`;
292
+ const writeRes2 = `workspace:write:${realRoot}`;
293
+ if (op.requiredResources.includes(writeRes1) ||
294
+ op.requiredResources.includes(writeRes2) ||
295
+ op.mutationRoots?.some((mr) => {
296
+ const resolved = path.resolve(mr);
297
+ return resolved === root || resolved === realRoot;
298
+ })) {
299
+ throw new ResourceConflictError(writeRes1, op.id, options.opId, 0);
300
+ }
301
+ }
302
+ }
303
+ // 检查是否有未裁决的 indeterminate op 占用该 root
304
+ const leases = this.domain.getStore().getPersistedResourceLeases(this.domain.domainId);
305
+ for (const lease of leases) {
306
+ for (const root of targetRoots) {
307
+ let realRoot = root;
308
+ try {
309
+ realRoot = fs.realpathSync(root);
310
+ }
311
+ catch { }
312
+ const writeRes1 = `workspace:write:${root}`;
313
+ const writeRes2 = `workspace:write:${realRoot}`;
314
+ if ((lease.resourceId === writeRes1 || lease.resourceId === writeRes2) && lease.operationId !== options.opId) {
315
+ throw new ResourceConflictError(lease.resourceId, lease.operationId, options.opId, 0);
316
+ }
317
+ }
318
+ }
319
+ const requiredResources = targetRoots.map((r) => `workspace:write:${r}`);
320
+ const inputFingerprint = crypto
321
+ .createHash('sha256')
322
+ .update(JSON.stringify({
323
+ kind: 'rollback',
324
+ snapshotId: options.snapshotId,
325
+ targetRoots,
326
+ capabilityId: options.capabilityId ?? null,
327
+ }))
328
+ .digest('hex');
329
+ const op = {
330
+ id: options.opId,
331
+ runId: options.runId,
332
+ kind: 'rollback',
333
+ name: `rollback:${options.snapshotId}`,
334
+ inputFingerprint,
335
+ requiredResources,
336
+ mutationRoots: targetRoots,
337
+ capabilityId: options.capabilityId,
338
+ outputRef: options.snapshotId,
339
+ timeoutMs: options.timeoutMs,
340
+ status: 'intent_registered',
341
+ };
342
+ this.domain.registerOperationIntent(op);
343
+ this.domain.getStore().updateOperationStatus(options.opId, 'active');
344
+ this.domain.getStore().recordEventAndTransitionState({
345
+ domainId: this.domain.domainId,
346
+ runId: options.runId,
347
+ operationId: options.opId,
348
+ type: 'OPERATION_STATUS_TRANSITION',
349
+ payload: { status: 'active' },
350
+ timestamp: new Date().toISOString(),
351
+ });
352
+ if (options.capabilityId && capAdmission) {
353
+ this.domain.getStore().recordJournalEvent({
354
+ domainId: this.domain.domainId,
355
+ runId: options.runId,
356
+ operationId: options.opId,
357
+ type: 'CAPABILITY_USED',
358
+ payload: {
359
+ capabilityId: options.capabilityId,
360
+ scope: capAdmission.capability.scope,
361
+ actor: capAdmission.capability.issuedBy,
362
+ },
363
+ timestamp: new Date().toISOString(),
364
+ });
365
+ }
366
+ try {
367
+ const { unrestoredPaths } = await this.snapshotDriver.restore(snapshot, { force: options.force });
368
+ const currentFp = await this.snapshotDriver.fingerprint(targetRoots, { against: snapshot });
369
+ const isMatch = currentFp === snapshot.treeFingerprint;
370
+ let status;
371
+ if (!isMatch) {
372
+ status = 'failed';
373
+ }
374
+ else if (unrestoredPaths.length > 0) {
375
+ status = 'partial';
376
+ }
377
+ else {
378
+ status = 'restored';
379
+ }
380
+ let allOpsConfined = false;
381
+ const eventsSince = snapshot.journalSeq !== undefined
382
+ ? this.domain.getStore().getJournalEvents(this.domain.domainId, snapshot.journalSeq)
383
+ : [];
384
+ const opResults = eventsSince.filter((e) => e.type === 'OPERATION_RESULT_RECORDED' && e.operationId !== options.opId);
385
+ if (opResults.length === 0) {
386
+ allOpsConfined = true;
387
+ }
388
+ else {
389
+ allOpsConfined = opResults.every((e) => {
390
+ const res = e.payload?.result || e.payload;
391
+ const storedOp = this.domain.getStore().getOperation(e.operationId);
392
+ if (storedOp?.kind === 'snapshot' || storedOp?.kind === 'rollback') {
393
+ return true;
394
+ }
395
+ if (res?.confined !== true) {
396
+ return false;
397
+ }
398
+ const opRoots = storedOp?.mutationRoots || [];
399
+ if (opRoots.length === 0)
400
+ return true;
401
+ return opRoots.every((opr) => targetRoots.some((sr) => isPathContained(sr, opr)));
402
+ });
403
+ }
404
+ let coverage;
405
+ let outOfScopeEffects;
406
+ if (status !== 'restored') {
407
+ coverage = status === 'partial' ? 'declared_roots' : 'none';
408
+ outOfScopeEffects = 'possible';
409
+ }
410
+ else if (allOpsConfined) {
411
+ coverage = 'complete';
412
+ outOfScopeEffects = 'none_possible';
413
+ }
414
+ else {
415
+ coverage = 'declared_roots';
416
+ outOfScopeEffects = 'possible';
417
+ }
418
+ let result;
419
+ this.domain.getStore().transaction(() => {
420
+ result = {
421
+ kind: 'rollback',
422
+ status,
423
+ snapshotId: options.snapshotId,
424
+ unrestoredPaths: unrestoredPaths.length > 0 ? unrestoredPaths : undefined,
425
+ coverage,
426
+ outOfScopeEffects,
427
+ capabilityId: options.capabilityId,
428
+ durationMs: Date.now() - startTime,
429
+ completedAt: new Date().toISOString(),
430
+ };
431
+ this.domain.getStore().recordOperationResult(options.opId, result);
432
+ this.domain.getStore().updateOperationStatus(options.opId, 'done');
433
+ });
434
+ this.domain.internalReleaseResources(options.opId);
435
+ return result;
436
+ }
437
+ catch (err) {
438
+ this.domain.internalReleaseResources(options.opId);
439
+ throw err;
440
+ }
441
+ }
442
+ /**
443
+ * 分叉工作区 Materialize (ARCHITECTURE §3.5 / 契约 #54)
444
+ */
445
+ async materialize(snapshotId, newRoot) {
446
+ const absNewRoot = path.resolve(newRoot);
447
+ const writeRes = `workspace:write:${absNewRoot}`;
448
+ if (this.domain.isResourceLocked(writeRes)) {
449
+ throw new ResourceConflictError(writeRes, this.domain.getResourceOwner(writeRes) || 'unknown', 'materialize');
450
+ }
451
+ const opId = `mat-${snapshotId}-${Date.now()}`;
452
+ this.domain.allocateResources(opId, [writeRes]);
453
+ try {
454
+ if (!this.snapshotDriver.materialize) {
455
+ throw new Error(`Snapshot driver ${this.snapshotDriver.name} does not support materialize`);
456
+ }
457
+ const snapshot = this.domain.getStore().getSnapshot(snapshotId);
458
+ const repoRoot = snapshot?.roots[0];
459
+ return await this.snapshotDriver.materialize(snapshotId, absNewRoot, { repoRoot });
460
+ }
461
+ catch (err) {
462
+ this.domain.internalReleaseResources(opId);
463
+ throw err;
464
+ }
465
+ }
466
+ /**
467
+ * 回收分叉工作区 Dematerialize (ARCHITECTURE §3.5 / 契约 #54)
468
+ */
469
+ async dematerialize(newRoot, options) {
470
+ const absNewRoot = path.resolve(newRoot);
471
+ let realNewRoot = absNewRoot;
472
+ try {
473
+ realNewRoot = fs.realpathSync(absNewRoot);
474
+ }
475
+ catch { }
476
+ if (!this.snapshotDriver.dematerialize) {
477
+ throw new Error(`Snapshot driver ${this.snapshotDriver.name} does not support dematerialize`);
478
+ }
479
+ await this.snapshotDriver.dematerialize(absNewRoot, options);
480
+ const writeRes = `workspace:write:${absNewRoot}`;
481
+ const writeResReal = `workspace:write:${realNewRoot}`;
482
+ const owner1 = this.domain.getResourceOwner(writeRes);
483
+ const owner2 = this.domain.getResourceOwner(writeResReal);
484
+ if (owner1) {
485
+ this.domain.internalReleaseResources(owner1, [writeRes]);
486
+ }
487
+ if (owner2 && owner2 !== owner1) {
488
+ this.domain.internalReleaseResources(owner2, [writeResReal]);
489
+ }
490
+ const leases = this.domain.getStore().getPersistedResourceLeases(this.domain.domainId);
491
+ for (const lease of leases) {
492
+ if (lease.resourceId === writeRes || lease.resourceId === writeResReal) {
493
+ this.domain.internalReleaseResources(lease.operationId, [lease.resourceId]);
494
+ }
495
+ }
496
+ }
497
+ /**
498
+ * 回收快照:删除驱动侧的私有 ref 与 store 记录,并写 SNAPSHOT_PRUNED。
499
+ * 宿主按自己的保留策略调用(例如每轮只保留会话基线与当前轮检查点),否则快照 ref 会无限累积。
500
+ * 未知 id 视为已回收(幂等);驱动删除失败直接抛出,store 记录保留,便于重试。
501
+ */
502
+ async pruneSnapshots(snapshotIds, options) {
503
+ const store = this.domain.getStore();
504
+ const pruned = [];
505
+ for (const id of snapshotIds) {
506
+ const snapshot = store.getSnapshot(id);
507
+ if (!snapshot)
508
+ continue;
509
+ store.verifyEpochFencing(this.domain.domainId);
510
+ const repoRoot = this.snapshotDriver instanceof GitShadowSnapshotDriver
511
+ ? (await this.snapshotDriver.assertGitRepo(snapshot.roots[0])).repoRoot
512
+ : undefined;
513
+ await this.snapshotDriver.prune([id], { repoRoot });
514
+ store.transaction(() => {
515
+ store.deleteSnapshot(id);
516
+ store.recordEventAndTransitionState({
517
+ domainId: this.domain.domainId,
518
+ runId: options?.runId,
519
+ type: 'SNAPSHOT_PRUNED',
520
+ payload: { snapshotId: id, roots: snapshot.roots },
521
+ timestamp: new Date().toISOString(),
522
+ });
523
+ });
524
+ pruned.push(id);
525
+ }
526
+ return pruned;
527
+ }
81
528
  /**
82
529
  * 启动协议:准入检查 -> 先持久化意图 -> 请求驱动启动 -> 登记身份并转 active -> 监督运行 -> 记录结果
83
530
  */
84
531
  async executeProcess(options) {
532
+ // 0. Capability 准入校验与字段推导(契约 #55)
533
+ let effectiveMutationRoots = options.mutationRoots;
534
+ let capAdmission;
535
+ if (options.capabilityId) {
536
+ let candidateRoots = options.mutationRoots ? [...options.mutationRoots] : [];
537
+ if (candidateRoots.length === 0 && options.command.cwd) {
538
+ candidateRoots = [options.command.cwd];
539
+ }
540
+ capAdmission = this.domain.checkCapabilityAdmission({
541
+ capabilityId: options.capabilityId,
542
+ requiredResources: options.requiredResources,
543
+ mutationRoots: candidateRoots.length > 0 ? candidateRoots : undefined,
544
+ runId: options.runId,
545
+ opId: options.opId,
546
+ });
547
+ options.requiredResources = capAdmission.effectiveResources;
548
+ effectiveMutationRoots = capAdmission.effectiveMutationRoots;
549
+ }
550
+ else {
551
+ options.requiredResources = (options.requiredResources || []).map(normalizeResourceName);
552
+ if (!effectiveMutationRoots || effectiveMutationRoots.length === 0) {
553
+ effectiveMutationRoots = options.requiredResources
554
+ .filter((r) => r.startsWith('workspace:write:'))
555
+ .map((r) => r.slice('workspace:write:'.length));
556
+ }
557
+ }
558
+ // 0. ConfinementDriver 驱动解析与包装(契约 #56)
559
+ let activeConfinementDriver;
560
+ if (options.confinementDriver) {
561
+ activeConfinementDriver = options.confinementDriver;
562
+ }
563
+ else if (typeof options.confinement === 'string') {
564
+ const driver = createConfinementDriver(options.confinement);
565
+ if (!driver) {
566
+ throw new Error(`Confinement driver '${options.confinement}' is not available on this platform`);
567
+ }
568
+ activeConfinementDriver = driver;
569
+ }
570
+ else if (options.confinement === true) {
571
+ const driver = this.confinementDriver || createConfinementDriver();
572
+ if (!driver) {
573
+ throw new Error('Confinement was requested but no confinement driver is available on this platform');
574
+ }
575
+ activeConfinementDriver = driver;
576
+ }
577
+ else if (this.confinementDriver) {
578
+ activeConfinementDriver = this.confinementDriver;
579
+ }
580
+ let effectiveCommand = options.command;
581
+ const isConfined = activeConfinementDriver !== undefined;
582
+ const confinementDriverName = activeConfinementDriver?.name;
583
+ if (activeConfinementDriver) {
584
+ effectiveCommand = activeConfinementDriver.wrap(options.command, effectiveMutationRoots || []);
585
+ }
85
586
  const startTime = Date.now();
86
587
  const maxBytes = options.maxOutputBytes ?? 10 * 1024 * 1024; // 默认 10MB
87
588
  const drainTimeoutMs = options.drainTimeoutMs ?? 2000; // 默认 2000ms
@@ -195,7 +696,9 @@ export class ProcessSupervisor {
195
696
  kind: 'process',
196
697
  name: options.name,
197
698
  inputFingerprint,
198
- requiredResources: options.requiredResources,
699
+ requiredResources: options.requiredResources || [],
700
+ mutationRoots: effectiveMutationRoots,
701
+ capabilityId: options.capabilityId,
199
702
  timeoutMs: options.timeoutMs,
200
703
  resourceBudget: options.resourceBudget,
201
704
  status: 'pending',
@@ -253,9 +756,9 @@ export class ProcessSupervisor {
253
756
  opState.phase = 'spawning';
254
757
  try {
255
758
  // 3. [启动协议步骤 2] 请求平台驱动启动
256
- handle = await this.driver.spawn(options.command);
759
+ handle = await this.driver.spawn(effectiveCommand);
257
760
  opState.handle = handle;
258
- opState.command = options.command;
761
+ opState.command = effectiveCommand;
259
762
  }
260
763
  catch (spawnError) {
261
764
  opState.phase = 'done';
@@ -270,6 +773,9 @@ export class ProcessSupervisor {
270
773
  stdout: '',
271
774
  stderr: spawnError?.message || String(spawnError),
272
775
  spawnFailure: spawnError?.message || String(spawnError),
776
+ capabilityId: options.capabilityId,
777
+ confined: isConfined,
778
+ confinementDriver: confinementDriverName,
273
779
  isTruncated: false,
274
780
  identityVerification: 'not_original_process',
275
781
  durationMs: Date.now() - startTime,
@@ -282,6 +788,20 @@ export class ProcessSupervisor {
282
788
  this.domain.getStore().updateOperationStatus(options.opId, 'active', handle.identity);
283
789
  opState.phase = 'active';
284
790
  handle.releaseGate?.();
791
+ if (options.capabilityId && capAdmission) {
792
+ this.domain.getStore().recordJournalEvent({
793
+ domainId: this.domain.domainId,
794
+ runId: options.runId,
795
+ operationId: options.opId,
796
+ type: 'CAPABILITY_USED',
797
+ payload: {
798
+ capabilityId: options.capabilityId,
799
+ scope: capAdmission.capability.scope,
800
+ actor: capAdmission.capability.issuedBy,
801
+ },
802
+ timestamp: new Date().toISOString(),
803
+ });
804
+ }
285
805
  }
286
806
  catch (statusError) {
287
807
  handle.destroyGate?.();
@@ -369,8 +889,8 @@ export class ProcessSupervisor {
369
889
  }
370
890
  }
371
891
  };
372
- stdoutDrainer = this.setupStreamDrainer(handle.stdout, maxBytes, stdoutSpillPath, onChunk, forwardChunk('stdout'), artifactsDirError);
373
- stderrDrainer = this.setupStreamDrainer(handle.stderr, maxBytes, stderrSpillPath, onChunk, forwardChunk('stderr'), artifactsDirError);
892
+ stdoutDrainer = setupStreamDrainer(handle.stdout, maxBytes, stdoutSpillPath, onChunk, forwardChunk('stdout'), artifactsDirError);
893
+ stderrDrainer = setupStreamDrainer(handle.stderr, maxBytes, stderrSpillPath, onChunk, forwardChunk('stderr'), artifactsDirError);
374
894
  // 检查在 spawn 途中是否已被请求取消:若已请求取消,立即触发停止流水线向底层发送终止信号
375
895
  if (opState.cancelRequested) {
376
896
  this.handleStopPipeline(options.opId, 'user_cancelled', opState.cancelGraceMs ?? 2000).catch(() => { });
@@ -538,6 +1058,19 @@ export class ProcessSupervisor {
538
1058
  // 排空超时路径强制调用 fsyncSync + closeSync 结清刷盘
539
1059
  stdoutDrainer.forceFinalize();
540
1060
  stderrDrainer.forceFinalize();
1061
+ // 走到这里时进程组已被确认停止,管道写端却仍被持有:
1062
+ // 必然存在一个驱动未观测到的逃逸进程(例如 setsid 后被 init 收养、采样器没来得及记录)。
1063
+ // 无法确认隔离,不能报告 succeeded / residualProcessesReaped,必须如实判 indeterminate 并保留租约。
1064
+ const indetResult = {
1065
+ kind: 'indeterminate',
1066
+ status: 'indeterminate',
1067
+ reason: `Process ${handle.identity.pid} exited (exitCode=${exitResult.exitCode}, signal=${exitResult.signal}) and its process group was confirmed stopped, but stdout/stderr pipes are still held by an unobserved escaped process`,
1068
+ recoveryGuidance: 'An escaped process outside the observed process tree still holds the output pipes. Inspect system processes manually before adjudicating and releasing resources.',
1069
+ capabilityId: options.capabilityId,
1070
+ durationMs: Date.now() - startTime,
1071
+ completedAt: new Date().toISOString(),
1072
+ };
1073
+ return this.finalizeOperation(options.opId, indetResult, options.requiredResources, false);
541
1074
  }
542
1075
  const stdoutData = stdoutDrainer.getResult();
543
1076
  const stderrData = stderrDrainer.getResult();
@@ -570,6 +1103,9 @@ export class ProcessSupervisor {
570
1103
  signal: exitResult.signal,
571
1104
  stdout: stdoutData.content,
572
1105
  stderr: stderrData.content,
1106
+ capabilityId: options.capabilityId,
1107
+ confined: isConfined,
1108
+ confinementDriver: confinementDriverName,
573
1109
  isTruncated,
574
1110
  stdoutTruncated: stdoutData.isTruncated,
575
1111
  stderrTruncated: stderrData.isTruncated,
@@ -605,6 +1141,7 @@ export class ProcessSupervisor {
605
1141
  status: 'indeterminate',
606
1142
  reason: `Process ${handle.identity.pid} was cancelled or stopped but could not be confirmed: ${stopRes.errorDetails || 'residual processes still alive'}`,
607
1143
  recoveryGuidance: 'Residual PID detected. Inspect system processes manually before releasing resources.',
1144
+ capabilityId: options.capabilityId,
608
1145
  durationMs: Date.now() - startTime,
609
1146
  completedAt: new Date().toISOString(),
610
1147
  };
@@ -731,6 +1268,7 @@ export class ProcessSupervisor {
731
1268
  signal: null,
732
1269
  stdout: '',
733
1270
  stderr: '',
1271
+ capabilityId: options.capabilityId,
734
1272
  isTruncated: false,
735
1273
  terminationReason: 'user_cancelled',
736
1274
  evidence: 'unobserved',
@@ -768,6 +1306,9 @@ export class ProcessSupervisor {
768
1306
  }
769
1307
  if (existing) {
770
1308
  result.runId = existing.runId;
1309
+ if (existing.capabilityId && !result.capabilityId) {
1310
+ result.capabilityId = existing.capabilityId;
1311
+ }
771
1312
  }
772
1313
  if (result.kind === 'process') {
773
1314
  const procRes = result;
@@ -807,178 +1348,5 @@ export class ProcessSupervisor {
807
1348
  }
808
1349
  return result;
809
1350
  }
810
- setupStreamDrainer(stream, maxBytes, spillFilePath, onChunk, onData, spillInitError) {
811
- const overhead = maxBytes >= 80 ? Math.min(64, Math.floor(maxBytes / 4)) : 0;
812
- const effectiveMax = maxBytes - overhead;
813
- const headMaxBytes = overhead > 0 ? Math.floor(effectiveMax * 0.75) : maxBytes;
814
- const tailMaxBytes = overhead > 0 ? Math.max(0, effectiveMax - headMaxBytes) : 0;
815
- const headChunks = [];
816
- let headBytes = 0;
817
- let bytesSeen = 0;
818
- let isTruncated = false;
819
- let spillFd = null;
820
- let spillError = spillInitError;
821
- const hash = crypto.createHash('sha256');
822
- let isFinalized = false;
823
- let outputHash = undefined;
824
- const tailRing = tailMaxBytes > 0 ? Buffer.alloc(tailMaxBytes) : null;
825
- let tailHead = 0;
826
- let tailCount = 0;
827
- if (spillFilePath && !spillError) {
828
- try {
829
- spillFd = fs.openSync(spillFilePath, 'w');
830
- }
831
- catch (openErr) {
832
- spillError = openErr.message || String(openErr);
833
- spillFd = null;
834
- }
835
- }
836
- const doFinalize = () => {
837
- if (isFinalized)
838
- return;
839
- isFinalized = true;
840
- if (spillFd !== null) {
841
- try {
842
- fs.fsyncSync(spillFd);
843
- fs.closeSync(spillFd);
844
- }
845
- catch (syncErr) {
846
- spillError = spillError || syncErr.message || String(syncErr);
847
- }
848
- spillFd = null;
849
- }
850
- if (!spillError) {
851
- try {
852
- outputHash = hash.digest('hex');
853
- }
854
- catch { }
855
- }
856
- else {
857
- outputHash = undefined;
858
- }
859
- };
860
- const finishPromise = new Promise((resolve) => {
861
- const finalize = () => {
862
- doFinalize();
863
- resolve();
864
- };
865
- stream.on('data', (chunk) => {
866
- if (isFinalized) {
867
- // finalize 之后,流继续读走数据避免堵塞或 SIGPIPE,但跳过 hash、落盘和转发
868
- return;
869
- }
870
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
871
- bytesSeen += buf.length;
872
- if (onChunk) {
873
- try {
874
- onChunk(buf.length);
875
- }
876
- catch { }
877
- }
878
- // 持续落盘转储全部流与写盘同链哈希 (P0-9)
879
- if (spillFd !== null) {
880
- try {
881
- fs.writeSync(spillFd, buf);
882
- hash.update(buf);
883
- }
884
- catch (writeErr) {
885
- spillError = spillError || writeErr.message || String(writeErr);
886
- try {
887
- fs.closeSync(spillFd);
888
- }
889
- catch { }
890
- spillFd = null;
891
- }
892
- }
893
- else if (!spillFilePath && !spillError) {
894
- try {
895
- hash.update(buf);
896
- }
897
- catch { }
898
- }
899
- if (onData) {
900
- onData(buf);
901
- }
902
- // Head 内存有界保留
903
- if (headBytes < headMaxBytes) {
904
- if (headBytes + buf.length <= headMaxBytes) {
905
- headChunks.push(buf);
906
- headBytes += buf.length;
907
- }
908
- else {
909
- const remaining = headMaxBytes - headBytes;
910
- if (remaining > 0) {
911
- headChunks.push(buf.subarray(0, remaining));
912
- headBytes += remaining;
913
- }
914
- isTruncated = true;
915
- }
916
- }
917
- else {
918
- isTruncated = true;
919
- }
920
- if (bytesSeen > maxBytes) {
921
- isTruncated = true;
922
- }
923
- // Tail 环形缓冲保留
924
- if (tailRing !== null && tailMaxBytes > 0) {
925
- if (buf.length >= tailMaxBytes) {
926
- buf.copy(tailRing, 0, buf.length - tailMaxBytes);
927
- tailHead = 0;
928
- tailCount = tailMaxBytes;
929
- }
930
- else {
931
- for (let i = 0; i < buf.length; i++) {
932
- tailRing[tailHead] = buf[i];
933
- tailHead = (tailHead + 1) % tailMaxBytes;
934
- }
935
- tailCount = Math.min(tailMaxBytes, tailCount + buf.length);
936
- }
937
- }
938
- });
939
- stream.on('end', finalize);
940
- stream.on('close', finalize);
941
- stream.on('error', finalize);
942
- });
943
- return {
944
- getResult: () => {
945
- doFinalize();
946
- let content;
947
- if (bytesSeen <= maxBytes) {
948
- content = Buffer.concat(headChunks).toString('utf8');
949
- }
950
- else {
951
- const rawHead = trimToValidUtf8(Buffer.concat(headChunks));
952
- let cleanTail = Buffer.alloc(0);
953
- if (tailRing !== null && tailCount > 0) {
954
- const rawTail = Buffer.alloc(tailCount);
955
- if (tailCount < tailMaxBytes) {
956
- tailRing.copy(rawTail, 0, 0, tailCount);
957
- }
958
- else {
959
- const part1 = tailRing.subarray(tailHead, tailMaxBytes);
960
- const part2 = tailRing.subarray(0, tailHead);
961
- part1.copy(rawTail, 0);
962
- part2.copy(rawTail, part1.length);
963
- }
964
- cleanTail = trimStartToValidUtf8(rawTail);
965
- }
966
- const truncatedBytes = Math.max(0, bytesSeen - rawHead.length - cleanTail.length);
967
- const marker = `\n[... truncated ${truncatedBytes} bytes ...]\n`;
968
- content = rawHead.toString('utf8') + marker + cleanTail.toString('utf8');
969
- }
970
- return {
971
- content,
972
- isTruncated,
973
- outputRef: isTruncated && spillFilePath && !spillError ? spillFilePath : undefined,
974
- outputHash: !spillError ? outputHash : undefined,
975
- bytesSeen,
976
- spillError,
977
- };
978
- },
979
- finishPromise,
980
- forceFinalize: doFinalize,
981
- };
982
- }
983
1351
  }
984
1352
  //# sourceMappingURL=supervisor.js.map