@zaalipro/dsh-workflows 0.1.0-rc.12
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/LICENSE +21 -0
- package/NOTICE.md +224 -0
- package/README.i18n.yaml +4 -0
- package/README.md +111 -0
- package/README.zh.md +111 -0
- package/cordis.patch.yml +10 -0
- package/docs/architecture.i18n.yaml +4 -0
- package/docs/architecture.md +177 -0
- package/docs/architecture.zh.md +177 -0
- package/docs/testing.i18n.yaml +4 -0
- package/docs/testing.md +125 -0
- package/docs/testing.zh.md +125 -0
- package/docs/user-guide.i18n.yaml +4 -0
- package/docs/user-guide.md +351 -0
- package/docs/user-guide.zh.md +351 -0
- package/lib/client-types/WorkflowMemberInspector.d.ts +28 -0
- package/lib/client-types/WorkflowMemberInspector.d.ts.map +1 -0
- package/lib/client-types/WorkflowMemberInspector.js +181 -0
- package/lib/client-types/WorkflowMemberInspector.js.map +1 -0
- package/lib/client-types/WorkflowRunPanel.d.ts +43 -0
- package/lib/client-types/WorkflowRunPanel.d.ts.map +1 -0
- package/lib/client-types/WorkflowRunPanel.js +130 -0
- package/lib/client-types/WorkflowRunPanel.js.map +1 -0
- package/lib/client-types/WorkflowsDashboard.d.ts +49 -0
- package/lib/client-types/WorkflowsDashboard.d.ts.map +1 -0
- package/lib/client-types/WorkflowsDashboard.js +942 -0
- package/lib/client-types/WorkflowsDashboard.js.map +1 -0
- package/lib/client-types/adapter.d.ts +60 -0
- package/lib/client-types/adapter.d.ts.map +1 -0
- package/lib/client-types/adapter.js +117 -0
- package/lib/client-types/adapter.js.map +1 -0
- package/lib/client-types/chat-renderer.d.ts +10 -0
- package/lib/client-types/chat-renderer.d.ts.map +1 -0
- package/lib/client-types/chat-renderer.js +11 -0
- package/lib/client-types/chat-renderer.js.map +1 -0
- package/lib/client-types/contract.d.ts +210 -0
- package/lib/client-types/contract.d.ts.map +1 -0
- package/lib/client-types/contract.js +37 -0
- package/lib/client-types/contract.js.map +1 -0
- package/lib/client-types/controller.d.ts +83 -0
- package/lib/client-types/controller.d.ts.map +1 -0
- package/lib/client-types/controller.js +705 -0
- package/lib/client-types/controller.js.map +1 -0
- package/lib/client-types/index.d.ts +19 -0
- package/lib/client-types/index.d.ts.map +1 -0
- package/lib/client-types/index.js +731 -0
- package/lib/client-types/index.js.map +1 -0
- package/lib/client-types/locales.d.ts +257 -0
- package/lib/client-types/locales.d.ts.map +1 -0
- package/lib/client-types/locales.js +270 -0
- package/lib/client-types/locales.js.map +1 -0
- package/lib/client-types/slot-components.d.ts +38 -0
- package/lib/client-types/slot-components.d.ts.map +1 -0
- package/lib/client-types/slot-components.js +22 -0
- package/lib/client-types/slot-components.js.map +1 -0
- package/lib/client-types/store.d.ts +69 -0
- package/lib/client-types/store.d.ts.map +1 -0
- package/lib/client-types/store.js +135 -0
- package/lib/client-types/store.js.map +1 -0
- package/lib/client-types/workflow-definition.d.ts +52 -0
- package/lib/client-types/workflow-definition.d.ts.map +1 -0
- package/lib/client-types/workflow-definition.js +135 -0
- package/lib/client-types/workflow-definition.js.map +1 -0
- package/lib/client.js +11065 -0
- package/lib/client.js.map +1 -0
- package/lib/compat-engine/index.js +1926 -0
- package/lib/compat-engine/index.js.map +1 -0
- package/lib/compat-engine/worker.cjs +1500 -0
- package/lib/compat-engine/worker.cjs.map +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1709 -0
- package/lib/typert.remote-client.d.ts +57 -0
- package/lib/typert.remote-client.js +1662 -0
- package/lib/types/commands/aliases.d.ts +4 -0
- package/lib/types/commands/aliases.d.ts.map +1 -0
- package/lib/types/commands/aliases.js +25 -0
- package/lib/types/commands/aliases.js.map +1 -0
- package/lib/types/commands/index.d.ts +66 -0
- package/lib/types/commands/index.d.ts.map +1 -0
- package/lib/types/commands/index.js +550 -0
- package/lib/types/commands/index.js.map +1 -0
- package/lib/types/commands/parser.d.ts +17 -0
- package/lib/types/commands/parser.d.ts.map +1 -0
- package/lib/types/commands/parser.js +45 -0
- package/lib/types/commands/parser.js.map +1 -0
- package/lib/types/config.d.ts +71 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +109 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +483 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts +21 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +85 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/registry/definition.d.ts +24 -0
- package/lib/types/registry/definition.d.ts.map +1 -0
- package/lib/types/registry/definition.js +189 -0
- package/lib/types/registry/definition.js.map +1 -0
- package/lib/types/registry/index.d.ts +100 -0
- package/lib/types/registry/index.d.ts.map +1 -0
- package/lib/types/registry/index.js +899 -0
- package/lib/types/registry/index.js.map +1 -0
- package/lib/types/registry/names.d.ts +19 -0
- package/lib/types/registry/names.d.ts.map +1 -0
- package/lib/types/registry/names.js +69 -0
- package/lib/types/registry/names.js.map +1 -0
- package/lib/types/registry/remote.d.ts +15 -0
- package/lib/types/registry/remote.d.ts.map +1 -0
- package/lib/types/registry/remote.js +128 -0
- package/lib/types/registry/remote.js.map +1 -0
- package/lib/types/registry/roots.d.ts +44 -0
- package/lib/types/registry/roots.d.ts.map +1 -0
- package/lib/types/registry/roots.js +118 -0
- package/lib/types/registry/roots.js.map +1 -0
- package/lib/types/registry/types.d.ts +62 -0
- package/lib/types/registry/types.d.ts.map +1 -0
- package/lib/types/registry/types.js +2 -0
- package/lib/types/registry/types.js.map +1 -0
- package/lib/types/registry/watchers.d.ts +70 -0
- package/lib/types/registry/watchers.d.ts.map +1 -0
- package/lib/types/registry/watchers.js +152 -0
- package/lib/types/registry/watchers.js.map +1 -0
- package/lib/types/remote-events.d.ts +42 -0
- package/lib/types/remote-events.d.ts.map +1 -0
- package/lib/types/remote-events.js +21 -0
- package/lib/types/remote-events.js.map +1 -0
- package/lib/types/run-recorder.d.ts +44 -0
- package/lib/types/run-recorder.d.ts.map +1 -0
- package/lib/types/run-recorder.js +356 -0
- package/lib/types/run-recorder.js.map +1 -0
- package/lib/types/supervisor/canned-validate.d.ts +25 -0
- package/lib/types/supervisor/canned-validate.d.ts.map +1 -0
- package/lib/types/supervisor/canned-validate.js +122 -0
- package/lib/types/supervisor/canned-validate.js.map +1 -0
- package/lib/types/supervisor/completion-notice.d.ts +49 -0
- package/lib/types/supervisor/completion-notice.d.ts.map +1 -0
- package/lib/types/supervisor/completion-notice.js +281 -0
- package/lib/types/supervisor/completion-notice.js.map +1 -0
- package/lib/types/supervisor/cursors.d.ts +68 -0
- package/lib/types/supervisor/cursors.d.ts.map +1 -0
- package/lib/types/supervisor/cursors.js +158 -0
- package/lib/types/supervisor/cursors.js.map +1 -0
- package/lib/types/supervisor/engine-compat.d.ts +48 -0
- package/lib/types/supervisor/engine-compat.d.ts.map +1 -0
- package/lib/types/supervisor/engine-compat.js +116 -0
- package/lib/types/supervisor/engine-compat.js.map +1 -0
- package/lib/types/supervisor/index.d.ts +183 -0
- package/lib/types/supervisor/index.d.ts.map +1 -0
- package/lib/types/supervisor/index.js +2040 -0
- package/lib/types/supervisor/index.js.map +1 -0
- package/lib/types/supervisor/parallel-compat.d.ts +36 -0
- package/lib/types/supervisor/parallel-compat.d.ts.map +1 -0
- package/lib/types/supervisor/parallel-compat.js +180 -0
- package/lib/types/supervisor/parallel-compat.js.map +1 -0
- package/lib/types/supervisor/remote.d.ts +25 -0
- package/lib/types/supervisor/remote.d.ts.map +1 -0
- package/lib/types/supervisor/remote.js +478 -0
- package/lib/types/supervisor/remote.js.map +1 -0
- package/lib/types/supervisor/storage/bounded-file.d.ts +41 -0
- package/lib/types/supervisor/storage/bounded-file.d.ts.map +1 -0
- package/lib/types/supervisor/storage/bounded-file.js +186 -0
- package/lib/types/supervisor/storage/bounded-file.js.map +1 -0
- package/lib/types/supervisor/storage/details-codec.d.ts +14 -0
- package/lib/types/supervisor/storage/details-codec.d.ts.map +1 -0
- package/lib/types/supervisor/storage/details-codec.js +359 -0
- package/lib/types/supervisor/storage/details-codec.js.map +1 -0
- package/lib/types/supervisor/storage/index.d.ts +26 -0
- package/lib/types/supervisor/storage/index.d.ts.map +1 -0
- package/lib/types/supervisor/storage/index.js +100 -0
- package/lib/types/supervisor/storage/index.js.map +1 -0
- package/lib/types/supervisor/storage/lease.d.ts +34 -0
- package/lib/types/supervisor/storage/lease.d.ts.map +1 -0
- package/lib/types/supervisor/storage/lease.js +289 -0
- package/lib/types/supervisor/storage/lease.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-codec.d.ts +10 -0
- package/lib/types/supervisor/storage/manifest-codec.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-codec.js +335 -0
- package/lib/types/supervisor/storage/manifest-codec.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-store.d.ts +99 -0
- package/lib/types/supervisor/storage/manifest-store.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-store.js +1606 -0
- package/lib/types/supervisor/storage/manifest-store.js.map +1 -0
- package/lib/types/supervisor/storage/manifest-types.d.ts +269 -0
- package/lib/types/supervisor/storage/manifest-types.d.ts.map +1 -0
- package/lib/types/supervisor/storage/manifest-types.js +2 -0
- package/lib/types/supervisor/storage/manifest-types.js.map +1 -0
- package/lib/types/supervisor/storage/private-root.d.ts +143 -0
- package/lib/types/supervisor/storage/private-root.d.ts.map +1 -0
- package/lib/types/supervisor/storage/private-root.js +860 -0
- package/lib/types/supervisor/storage/private-root.js.map +1 -0
- package/lib/types/supervisor/storage/recovery.d.ts +14 -0
- package/lib/types/supervisor/storage/recovery.d.ts.map +1 -0
- package/lib/types/supervisor/storage/recovery.js +126 -0
- package/lib/types/supervisor/storage/recovery.js.map +1 -0
- package/lib/types/supervisor/storage/run-files.d.ts +67 -0
- package/lib/types/supervisor/storage/run-files.d.ts.map +1 -0
- package/lib/types/supervisor/storage/run-files.js +346 -0
- package/lib/types/supervisor/storage/run-files.js.map +1 -0
- package/lib/types/supervisor/types.d.ts +295 -0
- package/lib/types/supervisor/types.d.ts.map +1 -0
- package/lib/types/supervisor/types.js +3 -0
- package/lib/types/supervisor/types.js.map +1 -0
- package/lib/types/supervisor/value-view.d.ts +19 -0
- package/lib/types/supervisor/value-view.d.ts.map +1 -0
- package/lib/types/supervisor/value-view.js +183 -0
- package/lib/types/supervisor/value-view.js.map +1 -0
- package/lib/types/tool/index.d.ts +86 -0
- package/lib/types/tool/index.d.ts.map +1 -0
- package/lib/types/tool/index.js +810 -0
- package/lib/types/tool/index.js.map +1 -0
- package/lib/types/tool/schema.d.ts +158 -0
- package/lib/types/tool/schema.d.ts.map +1 -0
- package/lib/types/tool/schema.js +137 -0
- package/lib/types/tool/schema.js.map +1 -0
- package/lib/types/types.d.ts +9 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +3 -0
- package/lib/types/types.js.map +1 -0
- package/lib/types/user-questions.d.ts +9 -0
- package/lib/types/user-questions.d.ts.map +1 -0
- package/lib/types/user-questions.js +91 -0
- package/lib/types/user-questions.js.map +1 -0
- package/package.json +291 -0
- package/skills/create-workflow/SKILL.md +166 -0
- package/vendor/workflow-engine/LICENSE +21 -0
- package/vendor/workflow-engine/README.md +1 -0
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isWorkflowDefinitionName } from '../../registry/names.js';
|
|
6
|
+
import { decodeWorkflowSessionManifest, displayOrdinal, encodeWorkflowSessionManifest } from './manifest-codec.js';
|
|
7
|
+
import { compactWorkflowRunDetails, decodeWorkflowRunDetails, encodeWorkflowRunDetails } from './details-codec.js';
|
|
8
|
+
import { BoundedFileError, assertSafeComponent, readBoundedUtf8, writeBoundedAtomic } from './bounded-file.js';
|
|
9
|
+
const SESSION_ADDRESS = /^[a-f0-9]{64}$/u;
|
|
10
|
+
const COMPONENT32 = /^[a-f0-9]{32}$/u;
|
|
11
|
+
const DETAIL_FILE = /^([a-f0-9]{32})\.json$/u;
|
|
12
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'interrupted']);
|
|
13
|
+
const ACTIVE = new Set(['running', 'pausing', 'stopping', 'needs-input', 'paused', 'budget-limited']);
|
|
14
|
+
const STORAGE_CATEGORIES = ['sessions', 'runs', 'staging', 'quarantine'];
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const now = () => Date.now();
|
|
17
|
+
const id = () => randomBytes(16).toString('hex');
|
|
18
|
+
const sessionAddress = (sessionId) => createHash('sha256').update(sessionId).digest('hex');
|
|
19
|
+
const clone = (value) => structuredClone(value);
|
|
20
|
+
const terminal = (status) => TERMINAL.has(status);
|
|
21
|
+
const descriptorFileCeiling = 1_048_576;
|
|
22
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
23
|
+
function artifactIdentityOf(value) {
|
|
24
|
+
return {
|
|
25
|
+
dev: Number(value.dev), ino: Number(value.ino), size: Number(value.size),
|
|
26
|
+
mtimeMs: Number(value.mtimeMs),
|
|
27
|
+
...(value.ctimeMs === undefined ? {} : { ctimeMs: Number(value.ctimeMs) }),
|
|
28
|
+
...(value.mode === undefined ? {} : { mode: Number(value.mode) }),
|
|
29
|
+
...(value.nlink === undefined ? {} : { nlink: Number(value.nlink) }),
|
|
30
|
+
...(value.version === undefined ? {} : { version: value.version }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function sameArtifactIdentity(left, right) {
|
|
34
|
+
if (left.version !== undefined || right.version !== undefined) {
|
|
35
|
+
return left.version !== undefined && right.version !== undefined && isSameOpaque(left.version, right.version)
|
|
36
|
+
&& left.size === right.size;
|
|
37
|
+
}
|
|
38
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
39
|
+
&& left.mtimeMs === right.mtimeMs
|
|
40
|
+
&& (left.ctimeMs === undefined || right.ctimeMs === undefined || left.ctimeMs === right.ctimeMs)
|
|
41
|
+
&& (left.nlink === undefined || right.nlink === undefined || left.nlink === right.nlink);
|
|
42
|
+
}
|
|
43
|
+
function isSameOpaque(left, right) {
|
|
44
|
+
if (Object.is(left, right))
|
|
45
|
+
return true;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function sameLocalIdentity(left, right) {
|
|
54
|
+
return Number(left.dev) === Number(right.dev)
|
|
55
|
+
&& Number(left.ino) === Number(right.ino)
|
|
56
|
+
&& Number(left.size) === Number(right.size)
|
|
57
|
+
&& Number(left.mtimeMs) === Number(right.mtimeMs)
|
|
58
|
+
&& Number(left.ctimeMs) === Number(right.ctimeMs);
|
|
59
|
+
}
|
|
60
|
+
/** Decode a bounded UTF-8 byte window without exposing a split code point. */
|
|
61
|
+
function decodeArtifactWindow(bytes, maxBytes) {
|
|
62
|
+
const capacity = Math.min(bytes.byteLength, maxBytes);
|
|
63
|
+
for (let count = capacity; count >= Math.max(0, capacity - 3); count -= 1) {
|
|
64
|
+
try {
|
|
65
|
+
return { text: utf8Decoder.decode(bytes.subarray(0, count)), returnedBytes: count };
|
|
66
|
+
}
|
|
67
|
+
catch { /* trim an incomplete trailing code point */ }
|
|
68
|
+
}
|
|
69
|
+
throw new BoundedFileError('workflow artifact is not valid UTF-8', 'WORKFLOW_STORAGE_CORRUPT');
|
|
70
|
+
}
|
|
71
|
+
function exposedHead(head, runsRoot) {
|
|
72
|
+
return { ...head, scriptPath: join(runsRoot, 'runs', head.runDirectory, 'script.js') };
|
|
73
|
+
}
|
|
74
|
+
function durableHead(head) {
|
|
75
|
+
const { scriptPath: _scriptPath, ...rest } = head;
|
|
76
|
+
return rest;
|
|
77
|
+
}
|
|
78
|
+
function detailLimits(options) {
|
|
79
|
+
return {
|
|
80
|
+
memberOutcomeMaxBytes: options.memberOutcomeMaxBytes ?? 131_072,
|
|
81
|
+
maxTerminalResultBytes: options.maxTerminalResultBytes ?? 1_048_576,
|
|
82
|
+
maxLogLineBytes: options.maxLogLineBytes ?? 65_536,
|
|
83
|
+
maxRunDetailsBytes: options.maxRunDetailsBytes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function unsafe(root, detail, cause) {
|
|
87
|
+
return new BoundedFileError(`workflow storage path "${root}" is unsafe: ${detail}`, 'WORKFLOW_STORAGE_UNSAFE', cause === undefined ? undefined : { cause });
|
|
88
|
+
}
|
|
89
|
+
/** Only explicit absence codes may become an empty/missing observation. */
|
|
90
|
+
function isNotFound(error) {
|
|
91
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
92
|
+
return false;
|
|
93
|
+
const code = String(error.code);
|
|
94
|
+
return code === 'ENOENT' || code === 'FS_NOT_FOUND';
|
|
95
|
+
}
|
|
96
|
+
async function ownerDirectory(path, create = false) {
|
|
97
|
+
try {
|
|
98
|
+
const info = await lstat(path);
|
|
99
|
+
if (info.isSymbolicLink() || !info.isDirectory() || (typeof process.getuid === 'function' && info.uid !== process.getuid()) || (info.mode & 0o777) !== 0o700)
|
|
100
|
+
throw unsafe(path, 'expected owner-only 0700 directory');
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== 'ENOENT' || !create)
|
|
104
|
+
throw error;
|
|
105
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
106
|
+
const info = await lstat(path);
|
|
107
|
+
if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o777) !== 0o700)
|
|
108
|
+
throw unsafe(path, 'created directory is unsafe');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function ownerFile(path, maxBytes) {
|
|
112
|
+
const nofollow = constants.O_NOFOLLOW;
|
|
113
|
+
if (!nofollow || process.platform === 'win32')
|
|
114
|
+
throw new BoundedFileError(`safe workflow storage is unavailable on ${process.platform}`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
115
|
+
const handle = await open(path, constants.O_RDONLY | nofollow);
|
|
116
|
+
try {
|
|
117
|
+
const info = await handle.stat();
|
|
118
|
+
if (!info.isFile() || info.nlink !== 1 || (typeof process.getuid === 'function' && info.uid !== process.getuid()) || (info.mode & 0o777) !== 0o600)
|
|
119
|
+
throw unsafe(path, 'expected owner-only 0600 regular file');
|
|
120
|
+
if (info.size > maxBytes)
|
|
121
|
+
throw new BoundedFileError(`${path} exceeds ${maxBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await handle.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function safeRemoveTree(path) {
|
|
128
|
+
const info = await lstat(path);
|
|
129
|
+
if (info.isSymbolicLink())
|
|
130
|
+
throw unsafe(path, 'symbolic links are not removable');
|
|
131
|
+
if (info.isDirectory()) {
|
|
132
|
+
const identity = { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs), ctimeMs: Number(info.ctimeMs) };
|
|
133
|
+
for (const name of (await readdir(path)).sort()) {
|
|
134
|
+
assertSafeComponent(name, 'storage entry');
|
|
135
|
+
const before = await lstat(join(path, name));
|
|
136
|
+
if (before.isSymbolicLink())
|
|
137
|
+
throw unsafe(join(path, name), 'symbolic links are not removable');
|
|
138
|
+
await safeRemoveTree(join(path, name));
|
|
139
|
+
}
|
|
140
|
+
const beforeRemove = await lstat(path);
|
|
141
|
+
if (!beforeRemove.isDirectory() || Number(beforeRemove.dev) !== identity.dev || Number(beforeRemove.ino) !== identity.ino)
|
|
142
|
+
throw unsafe(path, 'directory identity changed during cleanup');
|
|
143
|
+
await rmdir(path);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
if (!info.isFile() || info.nlink !== 1)
|
|
147
|
+
throw unsafe(path, 'unexpected removable file');
|
|
148
|
+
const current = await lstat(path);
|
|
149
|
+
if (!current.isFile() || Number(current.dev) !== Number(info.dev) || Number(current.ino) !== Number(info.ino) || Number(current.size) !== Number(info.size) || Number(current.mtimeMs) !== Number(info.mtimeMs) || Number(current.ctimeMs) !== Number(info.ctimeMs))
|
|
150
|
+
throw unsafe(path, 'file identity changed during cleanup');
|
|
151
|
+
await unlink(path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function treeBytes(path) {
|
|
155
|
+
const info = await lstat(path);
|
|
156
|
+
if (info.isSymbolicLink())
|
|
157
|
+
throw unsafe(path, 'symbolic links are not countable');
|
|
158
|
+
if (!info.isDirectory())
|
|
159
|
+
return info.size;
|
|
160
|
+
let total = 0;
|
|
161
|
+
for (const name of await readdir(path)) {
|
|
162
|
+
assertSafeComponent(name, 'storage entry');
|
|
163
|
+
total += await treeBytes(join(path, name));
|
|
164
|
+
}
|
|
165
|
+
return total;
|
|
166
|
+
}
|
|
167
|
+
/** Count every enumerated entry, not just Session manifests. Recovery uses a
|
|
168
|
+
* single budget so a hostile/deep tree cannot bypass the eager-scan bound by
|
|
169
|
+
* placing entries under runs, scratch, staging, or quarantine. */
|
|
170
|
+
async function countEntries(path, limit, seen) {
|
|
171
|
+
const info = await lstat(path);
|
|
172
|
+
if (info.isSymbolicLink())
|
|
173
|
+
throw unsafe(path, 'symbolic links are not allowed during recovery');
|
|
174
|
+
seen.value += 1;
|
|
175
|
+
if (seen.value > limit || !info.isDirectory())
|
|
176
|
+
return;
|
|
177
|
+
for (const name of (await readdir(path)).sort()) {
|
|
178
|
+
assertSafeComponent(name, 'storage entry');
|
|
179
|
+
await countEntries(join(path, name), limit, seen);
|
|
180
|
+
if (seen.value > limit)
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function countCapabilityEntries(directory, limit, seen, signal) {
|
|
185
|
+
signal?.throwIfAborted();
|
|
186
|
+
if (directory.listEntries === undefined) {
|
|
187
|
+
throw new BoundedFileError('descriptor-rooted recovery listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
188
|
+
}
|
|
189
|
+
const entries = await directory.listEntries(signal);
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
assertSafeComponent(entry.name, 'storage entry');
|
|
193
|
+
seen.value += 1;
|
|
194
|
+
if (seen.value > limit)
|
|
195
|
+
throw new BoundedFileError(`workflow storage path "${directory.path}" is unsafe: recovery scan exceeds ${limit} entries`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
196
|
+
if (entry.type === 'symlink')
|
|
197
|
+
throw unsafe(directory.path, `symbolic link encountered at "${entry.name}"`);
|
|
198
|
+
if (entry.type === 'directory') {
|
|
199
|
+
const child = await directory.openDirectory(entry.name, signal, { create: false });
|
|
200
|
+
try {
|
|
201
|
+
await countCapabilityEntries(child, limit, seen, signal);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await child.close().catch(() => undefined);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function recoveryLimitError(root, limit) {
|
|
210
|
+
return unsafe(root, `recovery scan exceeds ${limit} entries`);
|
|
211
|
+
}
|
|
212
|
+
function isSafeCapabilityIdentity(identity) {
|
|
213
|
+
if (typeof identity !== 'object' || identity === null)
|
|
214
|
+
return false;
|
|
215
|
+
const value = identity;
|
|
216
|
+
const size = Number(value.size);
|
|
217
|
+
const nlink = Number(value.nlink);
|
|
218
|
+
if (!Number.isSafeInteger(size) || size < 0 || nlink !== 1)
|
|
219
|
+
return false;
|
|
220
|
+
const hasOpaqueVersion = value.version !== undefined;
|
|
221
|
+
const hasPosixIdentity = Number.isSafeInteger(Number(value.dev)) && Number.isSafeInteger(Number(value.ino))
|
|
222
|
+
&& !Number.isNaN(Number(value.dev)) && !Number.isNaN(Number(value.ino));
|
|
223
|
+
return hasOpaqueVersion || hasPosixIdentity;
|
|
224
|
+
}
|
|
225
|
+
/** Validate and count a local descriptor tree without reading file bodies.
|
|
226
|
+
* This is used only by the standalone (no Host capability) fixture seam. */
|
|
227
|
+
async function inspectLocalTree(path, root, limit, count, maxFileBytes, signal) {
|
|
228
|
+
signal?.throwIfAborted();
|
|
229
|
+
let info;
|
|
230
|
+
try {
|
|
231
|
+
info = await lstat(path);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (error.code === 'ENOENT')
|
|
235
|
+
throw error;
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
if (info.isSymbolicLink())
|
|
239
|
+
throw unsafe(root, `symbolic link encountered at "${path}"`);
|
|
240
|
+
count.value += 1;
|
|
241
|
+
if (count.value > limit)
|
|
242
|
+
throw recoveryLimitError(root, limit);
|
|
243
|
+
if (info.isDirectory()) {
|
|
244
|
+
await ownerDirectory(path);
|
|
245
|
+
let total = 0;
|
|
246
|
+
for (const name of (await readdir(path)).sort()) {
|
|
247
|
+
assertSafeComponent(name, 'storage entry');
|
|
248
|
+
total += await inspectLocalTree(join(path, name), root, limit, count, maxFileBytes, signal);
|
|
249
|
+
}
|
|
250
|
+
return total;
|
|
251
|
+
}
|
|
252
|
+
if (!info.isFile())
|
|
253
|
+
throw unsafe(root, `unexpected entry type at "${path}"`);
|
|
254
|
+
await ownerFile(path, maxFileBytes);
|
|
255
|
+
return Number(info.size);
|
|
256
|
+
}
|
|
257
|
+
/** Count and validate a retained descriptor tree. No path is composed here;
|
|
258
|
+
* every descendant is opened through the already-pinned parent capability. */
|
|
259
|
+
async function inspectCapabilityTree(directory, root, limit, count, maxFileBytes, signal) {
|
|
260
|
+
signal?.throwIfAborted();
|
|
261
|
+
if (directory.listEntries === undefined)
|
|
262
|
+
throw new BoundedFileError('descriptor-rooted recovery listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
263
|
+
await directory.assertIdentity(signal);
|
|
264
|
+
const entries = await directory.listEntries(signal);
|
|
265
|
+
let total = 0;
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
signal?.throwIfAborted();
|
|
268
|
+
assertSafeComponent(entry.name, 'storage entry');
|
|
269
|
+
count.value += 1;
|
|
270
|
+
if (count.value > limit)
|
|
271
|
+
throw recoveryLimitError(root, limit);
|
|
272
|
+
if (entry.type === 'symlink')
|
|
273
|
+
throw unsafe(root, `symbolic link encountered at "${entry.name}"`);
|
|
274
|
+
if (entry.type === 'other')
|
|
275
|
+
throw unsafe(root, `unexpected entry type at "${entry.name}"`);
|
|
276
|
+
if (entry.type === 'directory') {
|
|
277
|
+
const child = await directory.openDirectory(entry.name, signal, { create: false });
|
|
278
|
+
try {
|
|
279
|
+
total += await inspectCapabilityTree(child, root, limit, count, maxFileBytes, signal);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await child.close().catch(() => undefined);
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const identity = entry.identity ?? (directory.fileInfo === undefined ? undefined : await directory.fileInfo(entry.name, signal));
|
|
287
|
+
if (!isSafeCapabilityIdentity(identity))
|
|
288
|
+
throw new BoundedFileError(`descriptor-rooted recovery metadata for "${entry.name}" is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
289
|
+
if (Number(identity.size) > maxFileBytes)
|
|
290
|
+
throw new BoundedFileError(`descriptor-rooted file "${entry.name}" exceeds ${maxFileBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
291
|
+
total += Number(identity.size);
|
|
292
|
+
}
|
|
293
|
+
await directory.assertIdentity(signal);
|
|
294
|
+
return total;
|
|
295
|
+
}
|
|
296
|
+
/** File-backed version-2 retained store. All durable mutations are serialized. */
|
|
297
|
+
function allocateDisplayIdentity(manifest, name) {
|
|
298
|
+
let ordinal = manifest.ordinals.find(item => item.name === name)?.next ?? 1;
|
|
299
|
+
for (;;) {
|
|
300
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 1) {
|
|
301
|
+
throw new BoundedFileError(`workflow display ordinal for "${name}" is exhausted`, 'WORKFLOW_STORAGE_LIMIT');
|
|
302
|
+
}
|
|
303
|
+
const displayName = ordinal === 1 ? name : `${name}-${ordinal}`;
|
|
304
|
+
const collision = manifest.ordinals.some(entry => {
|
|
305
|
+
try {
|
|
306
|
+
return displayOrdinal(entry.name, displayName) < entry.next;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
if (!collision)
|
|
313
|
+
return { ordinal, displayName };
|
|
314
|
+
ordinal += 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export class FileWorkflowRunStore {
|
|
318
|
+
options;
|
|
319
|
+
lease;
|
|
320
|
+
layout;
|
|
321
|
+
sessions = new Map();
|
|
322
|
+
details = new Map();
|
|
323
|
+
mutationTail = Promise.resolve();
|
|
324
|
+
disposed = false;
|
|
325
|
+
initialized = false;
|
|
326
|
+
recovered = [];
|
|
327
|
+
constructor(options, lease, layout) {
|
|
328
|
+
this.options = options;
|
|
329
|
+
this.lease = lease;
|
|
330
|
+
this.layout = layout;
|
|
331
|
+
for (const [name, value] of Object.entries(options)) {
|
|
332
|
+
if (name !== 'runsRoot' && value !== undefined && (!Number.isSafeInteger(value) || Number(value) <= 0))
|
|
333
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
334
|
+
}
|
|
335
|
+
if (options.maxManifestBytes > 8_388_608 || options.maxRunDetailsBytes > 33_554_432 || options.maxRunStoreBytes > 536_870_912 || options.maxRecoveryEntries > 4_096)
|
|
336
|
+
throw new RangeError('workflow storage limits exceed fixed ceilings');
|
|
337
|
+
}
|
|
338
|
+
async guard(signal) {
|
|
339
|
+
if (this.disposed)
|
|
340
|
+
throw new BoundedFileError('workflow run store is disposed');
|
|
341
|
+
signal?.throwIfAborted();
|
|
342
|
+
await this.lease?.assertCurrent();
|
|
343
|
+
signal?.throwIfAborted();
|
|
344
|
+
if (this.layout !== undefined) {
|
|
345
|
+
await this.layout.root.assertIdentity(signal);
|
|
346
|
+
for (const directory of [this.layout.sessions, this.layout.runs, this.layout.staging, this.layout.quarantine])
|
|
347
|
+
await directory.assertIdentity(signal);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async mutate(operation) {
|
|
351
|
+
const previous = this.mutationTail;
|
|
352
|
+
let release;
|
|
353
|
+
this.mutationTail = new Promise(resolve => { release = resolve; });
|
|
354
|
+
await previous;
|
|
355
|
+
try {
|
|
356
|
+
return await operation();
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
release();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
manifestPath(sessionId) { return join(this.options.runsRoot, 'sessions', sessionAddress(sessionId), 'manifest.json'); }
|
|
363
|
+
runPath(runDirectory) { assertSafeComponent(runDirectory, 'run directory'); return join(this.options.runsRoot, 'runs', runDirectory); }
|
|
364
|
+
empty(sessionId) { return { version: 2, sessionId, revision: 0, nextOrdinal: 1, ordinals: [], heads: [] }; }
|
|
365
|
+
/** Traverse a retained storage capability and close only capabilities this
|
|
366
|
+
* operation opened. The root/category descriptors belong to the bootstrap
|
|
367
|
+
* layout and are never closed here. */
|
|
368
|
+
async withDirectory(parts, operation, create = false, signal) {
|
|
369
|
+
if (this.layout === undefined)
|
|
370
|
+
throw new BoundedFileError('descriptor-rooted workflow storage is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
371
|
+
let directory = this.layout.root;
|
|
372
|
+
const opened = [];
|
|
373
|
+
try {
|
|
374
|
+
for (const part of parts) {
|
|
375
|
+
signal?.throwIfAborted();
|
|
376
|
+
directory = await directory.openDirectory(part, signal, { create });
|
|
377
|
+
opened.push(directory);
|
|
378
|
+
}
|
|
379
|
+
signal?.throwIfAborted();
|
|
380
|
+
return await operation(directory);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
for (const child of opened.reverse())
|
|
384
|
+
await child.close().catch(() => undefined);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async withRunDirectory(runDirectory, operation, signal) {
|
|
388
|
+
assertSafeComponent(runDirectory, 'run directory');
|
|
389
|
+
if (this.layout === undefined)
|
|
390
|
+
throw new BoundedFileError('descriptor-rooted workflow storage is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
391
|
+
return this.withDirectory(['runs', runDirectory], operation, false, signal);
|
|
392
|
+
}
|
|
393
|
+
async withCategory(category, operation) {
|
|
394
|
+
if (this.layout === undefined)
|
|
395
|
+
throw new BoundedFileError('descriptor-rooted workflow storage is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
396
|
+
const directory = this.layout[category];
|
|
397
|
+
await directory.assertIdentity();
|
|
398
|
+
try {
|
|
399
|
+
return await operation(directory);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
await directory.assertIdentity().catch(() => undefined);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** Remove a manifest-selected tree only through retained directory
|
|
406
|
+
* capabilities. The local path helpers below are reserved for the
|
|
407
|
+
* standalone fallback store; a Host layout without a removal primitive
|
|
408
|
+
* fails closed instead of recursing through strings. */
|
|
409
|
+
async removeCapabilityTree(category, name, signal) {
|
|
410
|
+
if (this.layout === undefined)
|
|
411
|
+
throw new BoundedFileError('descriptor-rooted workflow cleanup is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
412
|
+
assertSafeComponent(name, 'storage entry');
|
|
413
|
+
const parent = this.layout[category];
|
|
414
|
+
await parent.assertIdentity(signal);
|
|
415
|
+
const child = await parent.openDirectory(name, signal, { create: false });
|
|
416
|
+
try {
|
|
417
|
+
const entries = child.listEntries === undefined ? undefined : await child.listEntries(signal);
|
|
418
|
+
if (entries === undefined)
|
|
419
|
+
throw new BoundedFileError('descriptor-rooted workflow cleanup listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
signal?.throwIfAborted();
|
|
422
|
+
assertSafeComponent(entry.name, 'storage entry');
|
|
423
|
+
if (entry.type === 'file') {
|
|
424
|
+
if (child.removeFile === undefined)
|
|
425
|
+
throw new BoundedFileError('descriptor-rooted workflow file cleanup is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
426
|
+
await child.removeFile(entry.name, entry.identity, signal);
|
|
427
|
+
}
|
|
428
|
+
else if (entry.type === 'directory') {
|
|
429
|
+
// Recurse relative to the retained child, never from a joined path.
|
|
430
|
+
await this.removeCapabilitySubtree(child, entry.name, signal);
|
|
431
|
+
}
|
|
432
|
+
else
|
|
433
|
+
throw unsafe(this.options.runsRoot, `unsafe entry ${entry.name} during cleanup`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
await child.close().catch(() => undefined);
|
|
438
|
+
}
|
|
439
|
+
if (parent.removeDirectory === undefined)
|
|
440
|
+
throw new BoundedFileError('descriptor-rooted workflow directory cleanup is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
441
|
+
await parent.removeDirectory(name, undefined, signal);
|
|
442
|
+
await parent.assertIdentity(signal);
|
|
443
|
+
}
|
|
444
|
+
async removeCapabilitySubtree(parent, name, signal) {
|
|
445
|
+
assertSafeComponent(name, 'storage entry');
|
|
446
|
+
const child = await parent.openDirectory(name, signal, { create: false });
|
|
447
|
+
try {
|
|
448
|
+
const entries = child.listEntries === undefined ? undefined : await child.listEntries(signal);
|
|
449
|
+
if (entries === undefined)
|
|
450
|
+
throw new BoundedFileError('descriptor-rooted workflow cleanup listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
451
|
+
for (const entry of entries) {
|
|
452
|
+
signal?.throwIfAborted();
|
|
453
|
+
assertSafeComponent(entry.name, 'storage entry');
|
|
454
|
+
if (entry.type === 'file') {
|
|
455
|
+
if (child.removeFile === undefined)
|
|
456
|
+
throw new BoundedFileError('descriptor-rooted workflow file cleanup is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
457
|
+
await child.removeFile(entry.name, entry.identity, signal);
|
|
458
|
+
}
|
|
459
|
+
else if (entry.type === 'directory')
|
|
460
|
+
await this.removeCapabilitySubtree(child, entry.name, signal);
|
|
461
|
+
else
|
|
462
|
+
throw unsafe(this.options.runsRoot, `unsafe entry ${entry.name} during cleanup`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
await child.close().catch(() => undefined);
|
|
467
|
+
}
|
|
468
|
+
if (parent.removeDirectory === undefined)
|
|
469
|
+
throw new BoundedFileError('descriptor-rooted workflow directory cleanup is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
470
|
+
await parent.removeDirectory(name, undefined, signal);
|
|
471
|
+
}
|
|
472
|
+
async capabilityTreeBytes(directory, signal) {
|
|
473
|
+
const entries = directory.listEntries === undefined ? undefined : await directory.listEntries(signal);
|
|
474
|
+
if (entries === undefined)
|
|
475
|
+
throw new BoundedFileError('descriptor-rooted storage accounting is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
476
|
+
let total = 0;
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
signal?.throwIfAborted();
|
|
479
|
+
assertSafeComponent(entry.name, 'storage entry');
|
|
480
|
+
if (entry.type === 'file') {
|
|
481
|
+
const identity = entry.identity ?? (directory.fileInfo === undefined ? undefined : await directory.fileInfo(entry.name, signal));
|
|
482
|
+
if (identity === undefined || !Number.isSafeInteger(Number(identity.size)) || Number(identity.size) < 0) {
|
|
483
|
+
throw new BoundedFileError(`descriptor-rooted storage size for "${entry.name}" is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
484
|
+
}
|
|
485
|
+
total += Number(identity.size);
|
|
486
|
+
}
|
|
487
|
+
else if (entry.type === 'directory') {
|
|
488
|
+
const child = await directory.openDirectory(entry.name, signal, { create: false });
|
|
489
|
+
try {
|
|
490
|
+
total += await this.capabilityTreeBytes(child, signal);
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
await child.close().catch(() => undefined);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else
|
|
497
|
+
throw unsafe(this.options.runsRoot, `unsafe entry ${entry.name} during accounting`);
|
|
498
|
+
}
|
|
499
|
+
return total;
|
|
500
|
+
}
|
|
501
|
+
async committedBytes(signal) {
|
|
502
|
+
if (this.layout === undefined) {
|
|
503
|
+
let total = 0;
|
|
504
|
+
for (const category of ['sessions', 'runs', 'staging', 'quarantine'])
|
|
505
|
+
total += await treeBytes(join(this.options.runsRoot, category));
|
|
506
|
+
return total;
|
|
507
|
+
}
|
|
508
|
+
let total = 0;
|
|
509
|
+
for (const category of ['sessions', 'runs', 'staging', 'quarantine'])
|
|
510
|
+
total += await this.capabilityTreeBytes(this.layout[category], signal);
|
|
511
|
+
return total;
|
|
512
|
+
}
|
|
513
|
+
async removeRunTree(runDirectory, signal) {
|
|
514
|
+
await this.quarantineThenRemove('runs', runDirectory, signal);
|
|
515
|
+
}
|
|
516
|
+
async removeStagingTree(stageId, signal) {
|
|
517
|
+
await this.quarantineThenRemove('staging', stageId, signal);
|
|
518
|
+
}
|
|
519
|
+
/** Rename an identity-pinned run or staging tree into quarantine, then
|
|
520
|
+
* delete only through the quarantined child. An identity change aborts
|
|
521
|
+
* cleanup with UNSAFE rather than following a substituted path. */
|
|
522
|
+
async quarantineThenRemove(category, name, signal) {
|
|
523
|
+
assertSafeComponent(name, 'storage entry');
|
|
524
|
+
const quarantineId = id();
|
|
525
|
+
try {
|
|
526
|
+
if (this.layout !== undefined) {
|
|
527
|
+
const source = this.layout[category];
|
|
528
|
+
if (typeof source.publishDirectory !== 'function') {
|
|
529
|
+
throw new BoundedFileError('descriptor-rooted workflow directory publication is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
530
|
+
}
|
|
531
|
+
await source.assertIdentity(signal);
|
|
532
|
+
await this.layout.quarantine.assertIdentity(signal);
|
|
533
|
+
await source.publishDirectory(name, this.layout.quarantine, quarantineId, signal);
|
|
534
|
+
await this.removeCapabilityTree('quarantine', quarantineId, signal);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const from = join(this.options.runsRoot, category, name);
|
|
538
|
+
const to = join(this.options.runsRoot, 'quarantine', quarantineId);
|
|
539
|
+
await ownerDirectory(join(this.options.runsRoot, 'quarantine'), true);
|
|
540
|
+
await rename(from, to);
|
|
541
|
+
await safeRemoveTree(to);
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
if (isNotFound(error))
|
|
545
|
+
return;
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async readCapabilityFile(parts, name, maxBytes, signal) {
|
|
550
|
+
return this.withDirectory(parts, directory => directory.readBytes(name, signal, maxBytes), false);
|
|
551
|
+
}
|
|
552
|
+
async writeCapabilityFile(parts, name, content, maxBytes, createOnly = false, signal) {
|
|
553
|
+
const bytes = encoder.encode(content);
|
|
554
|
+
if (bytes.byteLength > maxBytes)
|
|
555
|
+
throw new BoundedFileError(`workflow storage file exceeds ${maxBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
556
|
+
await this.withDirectory(parts, async (directory) => {
|
|
557
|
+
let current = directory.fileInfo === undefined ? undefined : await directory.fileInfo(name, signal);
|
|
558
|
+
if (directory.fileInfo === undefined && directory.listEntries !== undefined) {
|
|
559
|
+
const observed = (await directory.listEntries(signal)).find(entry => entry.name === name);
|
|
560
|
+
if (observed !== undefined) {
|
|
561
|
+
if (observed.type !== 'file')
|
|
562
|
+
throw unsafe(directory.path, `entry "${name}" is not a regular file`);
|
|
563
|
+
current = observed.identity;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (createOnly && current !== undefined)
|
|
567
|
+
throw new BoundedFileError(`private file "${name}" already exists`, 'WORKFLOW_STORAGE_UNSAFE');
|
|
568
|
+
if (current !== undefined && current.version === undefined && Number.isNaN(current.dev)) {
|
|
569
|
+
throw new BoundedFileError(`descriptor-rooted version for "${name}" is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
570
|
+
}
|
|
571
|
+
const expected = current === undefined ? { kind: 'createIfAbsent' } : { kind: 'replaceIfVersion', version: current };
|
|
572
|
+
await directory.writeText(name, content, expected, signal);
|
|
573
|
+
}, true);
|
|
574
|
+
}
|
|
575
|
+
async removeCapabilityFile(parts, name, signal) {
|
|
576
|
+
if (this.layout === undefined) {
|
|
577
|
+
await unlink(join(this.options.runsRoot, ...parts, name)).catch(error => { if (error.code !== 'ENOENT')
|
|
578
|
+
throw error; });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
await this.withDirectory(parts, async (directory) => {
|
|
582
|
+
if (directory.removeFile === undefined)
|
|
583
|
+
throw new BoundedFileError('descriptor-rooted file removal is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
584
|
+
await directory.removeFile(name, undefined, signal);
|
|
585
|
+
}, false);
|
|
586
|
+
}
|
|
587
|
+
async persist(manifest) {
|
|
588
|
+
const durable = { ...manifest, heads: manifest.heads.map(durableHead) };
|
|
589
|
+
const bytes = encodeWorkflowSessionManifest(durable, this.options.maxManifestBytes);
|
|
590
|
+
const directory = join(this.options.runsRoot, 'sessions', sessionAddress(manifest.sessionId));
|
|
591
|
+
if (this.layout !== undefined) {
|
|
592
|
+
await this.writeCapabilityFile(['sessions', sessionAddress(manifest.sessionId)], 'manifest.json', new TextDecoder().decode(bytes), this.options.maxManifestBytes);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
await ownerDirectory(directory, true);
|
|
596
|
+
await writeBoundedAtomic(join(directory, 'manifest.json'), new TextDecoder().decode(bytes), { maxBytes: this.options.maxManifestBytes });
|
|
597
|
+
}
|
|
598
|
+
this.sessions.set(manifest.sessionId, clone(durable));
|
|
599
|
+
}
|
|
600
|
+
async load(sessionId) {
|
|
601
|
+
const cached = this.sessions.get(sessionId);
|
|
602
|
+
if (cached)
|
|
603
|
+
return clone(cached);
|
|
604
|
+
const path = this.manifestPath(sessionId);
|
|
605
|
+
try {
|
|
606
|
+
const bytes = this.layout === undefined
|
|
607
|
+
? encoder.encode(await (async () => { await ownerFile(path, this.options.maxManifestBytes); return readBoundedUtf8(path, this.options.maxManifestBytes); })())
|
|
608
|
+
: await this.readCapabilityFile(['sessions', sessionAddress(sessionId)], 'manifest.json', this.options.maxManifestBytes);
|
|
609
|
+
const manifest = decodeWorkflowSessionManifest(new TextDecoder().decode(bytes), path, this.options.maxManifestBytes);
|
|
610
|
+
if (manifest.sessionId !== sessionId)
|
|
611
|
+
throw unsafe(this.options.runsRoot, 'Session manifest identity mismatch');
|
|
612
|
+
this.sessions.set(sessionId, clone(manifest));
|
|
613
|
+
return clone(manifest);
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (isNotFound(error)) {
|
|
617
|
+
const empty = this.empty(sessionId);
|
|
618
|
+
this.sessions.set(sessionId, empty);
|
|
619
|
+
return clone(empty);
|
|
620
|
+
}
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async readDetail(head, sessionId, cache = true) {
|
|
625
|
+
const cached = this.details.get(head.runId);
|
|
626
|
+
if (cache && cached?.detailId === head.detail.id)
|
|
627
|
+
return clone(cached);
|
|
628
|
+
const path = join(this.runPath(head.runDirectory), 'details', `${head.detail.id}.json`);
|
|
629
|
+
const bytes = this.layout === undefined
|
|
630
|
+
? encoder.encode(await (async () => { await ownerFile(path, this.options.maxRunDetailsBytes); return readBoundedUtf8(path, this.options.maxRunDetailsBytes); })())
|
|
631
|
+
: await this.readCapabilityFile(['runs', head.runDirectory, 'details'], `${head.detail.id}.json`, this.options.maxRunDetailsBytes);
|
|
632
|
+
if (bytes.byteLength !== head.detail.bytes || createHash('sha256').update(bytes).digest('hex') !== head.detail.sha256)
|
|
633
|
+
throw unsafe(this.options.runsRoot, `detail ${head.detail.id} digest or byte count does not match its manifest head`);
|
|
634
|
+
const detail = decodeWorkflowRunDetails(bytes, path, detailLimits(this.options));
|
|
635
|
+
if (detail.sessionId !== sessionId || detail.runId !== head.runId || detail.runDirectory !== head.runDirectory || detail.detailId !== head.detail.id || detail.snapshotRevision !== head.detail.snapshotRevision)
|
|
636
|
+
throw unsafe(this.options.runsRoot, 'detail identity does not match its manifest head');
|
|
637
|
+
if (cache)
|
|
638
|
+
this.details.set(head.runId, clone(detail));
|
|
639
|
+
return detail;
|
|
640
|
+
}
|
|
641
|
+
/** Read the editable projection through the retained storage capability.
|
|
642
|
+
* The caller supplies only the manifest-selected single component; no
|
|
643
|
+
* absolute path is opened here. */
|
|
644
|
+
async readRunScript(runDirectory, maxBytes, signal) {
|
|
645
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > descriptorFileCeiling)
|
|
646
|
+
throw new BoundedFileError('workflow script projection limit is invalid', 'WORKFLOW_STORAGE_LIMIT');
|
|
647
|
+
await this.mutate(async () => {
|
|
648
|
+
await this.guard(signal);
|
|
649
|
+
await this.ensureInitialized(signal);
|
|
650
|
+
});
|
|
651
|
+
return this.withRunDirectory(runDirectory, async (directory) => {
|
|
652
|
+
const bytes = await directory.readBytes('script.js', signal, maxBytes);
|
|
653
|
+
try {
|
|
654
|
+
return utf8Decoder.decode(bytes);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
throw new BoundedFileError('workflow script projection is not valid UTF-8', 'WORKFLOW_STORAGE_CORRUPT', { cause: error });
|
|
658
|
+
}
|
|
659
|
+
}, signal);
|
|
660
|
+
}
|
|
661
|
+
/** Inventory scratch files relative to a descriptor-pinned run directory. */
|
|
662
|
+
async listRunArtifacts(runDirectory, maxItems, signal) {
|
|
663
|
+
if (!Number.isSafeInteger(maxItems) || maxItems < 1)
|
|
664
|
+
throw new BoundedFileError('workflow artifact limit is invalid', 'WORKFLOW_STORAGE_LIMIT');
|
|
665
|
+
await this.mutate(async () => {
|
|
666
|
+
await this.guard(signal);
|
|
667
|
+
await this.ensureInitialized(signal);
|
|
668
|
+
});
|
|
669
|
+
return this.withRunDirectory(runDirectory, async (directory) => {
|
|
670
|
+
const scratch = await directory.openDirectory('scratch', signal, { create: false });
|
|
671
|
+
try {
|
|
672
|
+
if (scratch.listEntries === undefined) {
|
|
673
|
+
throw new BoundedFileError('descriptor-rooted scratch listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
674
|
+
}
|
|
675
|
+
const entries = await scratch.listEntries(signal);
|
|
676
|
+
const all = [];
|
|
677
|
+
for (const entry of entries) {
|
|
678
|
+
signal?.throwIfAborted();
|
|
679
|
+
assertSafeComponent(entry.name, 'scratch artifact');
|
|
680
|
+
if (entry.type !== 'file')
|
|
681
|
+
throw unsafe(this.options.runsRoot, `unsafe scratch artifact ${entry.name}`);
|
|
682
|
+
const identity = entry.identity ?? (scratch.fileInfo === undefined ? undefined : await scratch.fileInfo(entry.name, signal));
|
|
683
|
+
if (identity === undefined)
|
|
684
|
+
throw new BoundedFileError('descriptor-rooted scratch metadata is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
685
|
+
if (identity.nlink !== 1)
|
|
686
|
+
throw unsafe(this.options.runsRoot, `unsafe scratch artifact ${entry.name}`);
|
|
687
|
+
const size = Number(identity.size);
|
|
688
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > descriptorFileCeiling)
|
|
689
|
+
throw new BoundedFileError(`workflow artifact ${entry.name} exceeds ${descriptorFileCeiling} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
690
|
+
all.push({ name: entry.name, bytes: size, identity: artifactIdentityOf(identity) });
|
|
691
|
+
}
|
|
692
|
+
all.sort((left, right) => left.name.localeCompare(right.name));
|
|
693
|
+
return { items: all.slice(0, maxItems), total: all.length };
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
await scratch.close().catch(() => undefined);
|
|
697
|
+
}
|
|
698
|
+
}, signal);
|
|
699
|
+
}
|
|
700
|
+
/** Read a bounded UTF-8 artifact window while retaining the descriptor for
|
|
701
|
+
* both identity checks. */
|
|
702
|
+
async readRunArtifact(runDirectory, name, offsetBytes, maxBytes, expected, signal) {
|
|
703
|
+
assertSafeComponent(name, 'scratch artifact');
|
|
704
|
+
if (!Number.isSafeInteger(offsetBytes) || offsetBytes < 0 || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 131_072)
|
|
705
|
+
throw new BoundedFileError('workflow artifact read limit is invalid', 'WORKFLOW_STORAGE_LIMIT');
|
|
706
|
+
await this.mutate(async () => {
|
|
707
|
+
await this.guard(signal);
|
|
708
|
+
await this.ensureInitialized(signal);
|
|
709
|
+
});
|
|
710
|
+
return this.withRunDirectory(runDirectory, async (directory) => {
|
|
711
|
+
const scratch = await directory.openDirectory('scratch', signal, { create: false });
|
|
712
|
+
try {
|
|
713
|
+
const before = scratch.fileInfo === undefined ? undefined : await scratch.fileInfo(name, signal);
|
|
714
|
+
if (before === undefined || before.nlink !== 1 || before.size < offsetBytes || !sameArtifactIdentity(artifactIdentityOf(before), expected))
|
|
715
|
+
throw new BoundedFileError('workflow artifact changed; refresh it before reading', 'WORKFLOW_STALE_REVISION');
|
|
716
|
+
const bytes = await scratch.readBytes(name, signal, before.size);
|
|
717
|
+
const window = bytes.subarray(offsetBytes, Math.min(bytes.byteLength, offsetBytes + maxBytes));
|
|
718
|
+
const decoded = decodeArtifactWindow(window, maxBytes);
|
|
719
|
+
const after = scratch.fileInfo === undefined ? before : await scratch.fileInfo(name, signal);
|
|
720
|
+
if (after === undefined || !sameArtifactIdentity(artifactIdentityOf(after), expected))
|
|
721
|
+
throw new BoundedFileError('workflow artifact changed while it was being read', 'WORKFLOW_STALE_REVISION');
|
|
722
|
+
return {
|
|
723
|
+
text: decoded.text, offsetBytes, returnedBytes: decoded.returnedBytes,
|
|
724
|
+
totalBytes: before.size, identity: artifactIdentityOf(after),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
await scratch.close().catch(() => undefined);
|
|
729
|
+
}
|
|
730
|
+
}, signal);
|
|
731
|
+
}
|
|
732
|
+
async readRunReport(runDirectory, maxBytes, signal) {
|
|
733
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > descriptorFileCeiling)
|
|
734
|
+
throw new BoundedFileError('workflow report limit is invalid', 'WORKFLOW_STORAGE_LIMIT');
|
|
735
|
+
await this.mutate(async () => { await this.guard(signal); await this.ensureInitialized(signal); });
|
|
736
|
+
return this.withRunDirectory(runDirectory, async (directory) => {
|
|
737
|
+
const scratch = await directory.openDirectory('scratch', signal, { create: false });
|
|
738
|
+
try {
|
|
739
|
+
if (scratch.fileInfo === undefined) {
|
|
740
|
+
throw new BoundedFileError('descriptor-rooted scratch metadata is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
741
|
+
}
|
|
742
|
+
const info = await scratch.fileInfo('report.md', signal);
|
|
743
|
+
if (info === undefined)
|
|
744
|
+
return undefined;
|
|
745
|
+
if (info.nlink !== 1 || info.size > maxBytes)
|
|
746
|
+
throw new BoundedFileError('workflow report exceeds its bound', 'WORKFLOW_STORAGE_LIMIT');
|
|
747
|
+
const bytes = await scratch.readBytes('report.md', signal, maxBytes);
|
|
748
|
+
return utf8Decoder.decode(bytes);
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
if (isNotFound(error))
|
|
752
|
+
return undefined;
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
await scratch.close().catch(() => undefined);
|
|
757
|
+
}
|
|
758
|
+
}, signal);
|
|
759
|
+
}
|
|
760
|
+
async validateRunFiles(head, sessionId) {
|
|
761
|
+
const run = this.runPath(head.runDirectory);
|
|
762
|
+
if (this.layout !== undefined) {
|
|
763
|
+
await this.withDirectory(['runs', head.runDirectory], async (runDirectory) => {
|
|
764
|
+
await runDirectory.readBytes('script.js', undefined, 1_048_576);
|
|
765
|
+
const scratch = await runDirectory.openDirectory('scratch', undefined, { create: false });
|
|
766
|
+
const details = await runDirectory.openDirectory('details', undefined, { create: false });
|
|
767
|
+
try {
|
|
768
|
+
if (details.listEntries === undefined)
|
|
769
|
+
throw new BoundedFileError('descriptor-rooted detail listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
770
|
+
const entries = await details.listEntries();
|
|
771
|
+
let bytes = 0;
|
|
772
|
+
for (const entry of entries) {
|
|
773
|
+
if (!/^[a-f0-9]{32}\.json$/u.test(entry.name) || entry.type !== 'file')
|
|
774
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${entry.name}`);
|
|
775
|
+
const identity = entry.identity ?? (details.fileInfo === undefined ? undefined : await details.fileInfo(entry.name));
|
|
776
|
+
if (identity === undefined)
|
|
777
|
+
throw new BoundedFileError(`descriptor-rooted detail metadata for "${entry.name}" is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
778
|
+
bytes += Number(identity.size);
|
|
779
|
+
if (bytes > this.options.maxRunDetailsBytes)
|
|
780
|
+
throw new BoundedFileError(`run ${head.runDirectory} details exceed ${this.options.maxRunDetailsBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
await details.close().catch(() => undefined);
|
|
785
|
+
await scratch.close().catch(() => undefined);
|
|
786
|
+
}
|
|
787
|
+
}, false);
|
|
788
|
+
await this.readDetail(head, sessionId);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
await ownerDirectory(run);
|
|
792
|
+
await ownerFile(join(run, 'script.js'), 1_048_576);
|
|
793
|
+
await ownerDirectory(join(run, 'scratch'));
|
|
794
|
+
await ownerDirectory(join(run, 'details'));
|
|
795
|
+
let bytes = 0;
|
|
796
|
+
for (const name of await readdir(join(run, 'details'))) {
|
|
797
|
+
if (!/^[a-f0-9]{32}\.json$/u.test(name))
|
|
798
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${name}`);
|
|
799
|
+
const path = join(run, 'details', name);
|
|
800
|
+
await ownerFile(path, this.options.maxRunDetailsBytes);
|
|
801
|
+
bytes += (await stat(path)).size;
|
|
802
|
+
if (bytes > this.options.maxRunDetailsBytes)
|
|
803
|
+
throw new BoundedFileError(`run ${head.runDirectory} details exceed ${this.options.maxRunDetailsBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
804
|
+
}
|
|
805
|
+
await this.readDetail(head, sessionId);
|
|
806
|
+
}
|
|
807
|
+
/** Read and decode every detail sidecar in one run during the read-only
|
|
808
|
+
* recovery preflight. In particular, unreferenced immutable snapshots are
|
|
809
|
+
* decoded too: silently deleting a malformed transaction residue would turn
|
|
810
|
+
* a corrupt store into a successful partial recovery. */
|
|
811
|
+
async validateLocalRunInventory(runDirectory, inventory) {
|
|
812
|
+
const run = this.runPath(runDirectory);
|
|
813
|
+
const children = (await readdir(run)).sort();
|
|
814
|
+
if (children.length !== 3 || children.join('\u0000') !== ['details', 'scratch', 'script.js'].join('\u0000')) {
|
|
815
|
+
throw unsafe(this.options.runsRoot, `run ${runDirectory} has an unexpected layout`);
|
|
816
|
+
}
|
|
817
|
+
await ownerFile(join(run, 'script.js'), 1_048_576);
|
|
818
|
+
await ownerDirectory(join(run, 'scratch'));
|
|
819
|
+
await ownerDirectory(join(run, 'details'));
|
|
820
|
+
let total = 0;
|
|
821
|
+
for (const name of (await readdir(join(run, 'details'))).sort()) {
|
|
822
|
+
const match = name.match(DETAIL_FILE);
|
|
823
|
+
if (!match)
|
|
824
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${name}`);
|
|
825
|
+
const path = join(run, 'details', name);
|
|
826
|
+
await ownerFile(path, this.options.maxRunDetailsBytes);
|
|
827
|
+
const info = await stat(path);
|
|
828
|
+
total += Number(info.size);
|
|
829
|
+
if (total > this.options.maxRunDetailsBytes) {
|
|
830
|
+
throw new BoundedFileError(`run ${runDirectory} details exceed ${this.options.maxRunDetailsBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
831
|
+
}
|
|
832
|
+
const bytes = encoder.encode(await readBoundedUtf8(path, this.options.maxRunDetailsBytes));
|
|
833
|
+
const snapshot = decodeWorkflowRunDetails(new TextDecoder().decode(bytes), path, detailLimits(this.options));
|
|
834
|
+
if (snapshot.runDirectory !== runDirectory || snapshot.detailId !== match[1]) {
|
|
835
|
+
throw unsafe(this.options.runsRoot, `detail ${name} identity does not match run ${runDirectory}`);
|
|
836
|
+
}
|
|
837
|
+
const key = `${runDirectory}/${match[1]}`;
|
|
838
|
+
if (inventory.details.has(key))
|
|
839
|
+
throw unsafe(this.options.runsRoot, `detail ${match[1]} is duplicated`);
|
|
840
|
+
inventory.details.set(key, snapshot);
|
|
841
|
+
inventory.detailDigests.set(key, { bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async capabilityEntries(directory, signal) {
|
|
845
|
+
if (directory.listEntries === undefined)
|
|
846
|
+
throw new BoundedFileError('descriptor-rooted recovery listing is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
847
|
+
return directory.listEntries(signal);
|
|
848
|
+
}
|
|
849
|
+
async validateCapabilityRunInventory(runDirectory, inventory, signal) {
|
|
850
|
+
const run = await this.layout.runs.openDirectory(runDirectory, signal, { create: false });
|
|
851
|
+
try {
|
|
852
|
+
const children = await this.capabilityEntries(run, signal);
|
|
853
|
+
const names = children.map(entry => entry.name).sort();
|
|
854
|
+
if (names.length !== 3 || names.join('\u0000') !== ['details', 'scratch', 'script.js'].join('\u0000')) {
|
|
855
|
+
throw unsafe(this.options.runsRoot, `run ${runDirectory} has an unexpected layout`);
|
|
856
|
+
}
|
|
857
|
+
const script = children.find(entry => entry.name === 'script.js');
|
|
858
|
+
if (script?.type !== 'file')
|
|
859
|
+
throw unsafe(this.options.runsRoot, `run ${runDirectory} script is not a regular file`);
|
|
860
|
+
const scriptIdentity = script.identity ?? (run.fileInfo === undefined ? undefined : await run.fileInfo('script.js', signal));
|
|
861
|
+
if (!isSafeCapabilityIdentity(scriptIdentity) || Number(scriptIdentity.size) > 1_048_576) {
|
|
862
|
+
throw new BoundedFileError(`descriptor-rooted script metadata for ${runDirectory} is unavailable or oversized`, 'WORKFLOW_STORAGE_LIMIT');
|
|
863
|
+
}
|
|
864
|
+
const scratchEntry = children.find(entry => entry.name === 'scratch');
|
|
865
|
+
const detailsEntry = children.find(entry => entry.name === 'details');
|
|
866
|
+
if (scratchEntry?.type !== 'directory' || detailsEntry?.type !== 'directory')
|
|
867
|
+
throw unsafe(this.options.runsRoot, `run ${runDirectory} scratch/details layout is unsafe`);
|
|
868
|
+
const details = await run.openDirectory('details', signal, { create: false });
|
|
869
|
+
const scratch = await run.openDirectory('scratch', signal, { create: false });
|
|
870
|
+
try {
|
|
871
|
+
// A scratch entry is allowed to be empty or to contain model-created
|
|
872
|
+
// artifacts, but every entry must still be accounted for by the
|
|
873
|
+
// aggregate inventory pass above.
|
|
874
|
+
void await this.capabilityEntries(scratch, signal);
|
|
875
|
+
const detailEntries = await this.capabilityEntries(details, signal);
|
|
876
|
+
let total = 0;
|
|
877
|
+
for (const entry of detailEntries) {
|
|
878
|
+
signal?.throwIfAborted();
|
|
879
|
+
const match = entry.name.match(DETAIL_FILE);
|
|
880
|
+
if (!match || entry.type !== 'file')
|
|
881
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${entry.name}`);
|
|
882
|
+
const identity = entry.identity ?? (details.fileInfo === undefined ? undefined : await details.fileInfo(entry.name, signal));
|
|
883
|
+
if (!isSafeCapabilityIdentity(identity))
|
|
884
|
+
throw new BoundedFileError(`descriptor-rooted detail metadata for "${entry.name}" is unavailable`, 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
885
|
+
total += Number(identity.size);
|
|
886
|
+
if (total > this.options.maxRunDetailsBytes)
|
|
887
|
+
throw new BoundedFileError(`run ${runDirectory} details exceed ${this.options.maxRunDetailsBytes} bytes`, 'WORKFLOW_STORAGE_LIMIT');
|
|
888
|
+
const bytes = await details.readBytes(entry.name, signal, this.options.maxRunDetailsBytes);
|
|
889
|
+
await details.assertIdentity(signal);
|
|
890
|
+
const file = join(this.options.runsRoot, 'runs', runDirectory, 'details', entry.name);
|
|
891
|
+
const snapshot = decodeWorkflowRunDetails(bytes, file, detailLimits(this.options));
|
|
892
|
+
if (snapshot.runDirectory !== runDirectory || snapshot.detailId !== match[1])
|
|
893
|
+
throw unsafe(this.options.runsRoot, `detail ${entry.name} identity does not match run ${runDirectory}`);
|
|
894
|
+
const key = `${runDirectory}/${match[1]}`;
|
|
895
|
+
if (inventory.details.has(key))
|
|
896
|
+
throw unsafe(this.options.runsRoot, `detail ${match[1]} is duplicated`);
|
|
897
|
+
inventory.details.set(key, snapshot);
|
|
898
|
+
inventory.detailDigests.set(key, { bytes: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
await details.close().catch(() => undefined);
|
|
903
|
+
await scratch.close().catch(() => undefined);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
finally {
|
|
907
|
+
await run.close().catch(() => undefined);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/** Complete, read-only recovery validation. Nothing is cached or removed
|
|
911
|
+
* until this method has checked every category, manifest, run, and detail
|
|
912
|
+
* sidecar. */
|
|
913
|
+
async validateRecoveryInventory(signal) {
|
|
914
|
+
const count = { value: 0 };
|
|
915
|
+
const details = new Map();
|
|
916
|
+
const detailDigests = new Map();
|
|
917
|
+
const manifests = [];
|
|
918
|
+
const runDirectories = new Set();
|
|
919
|
+
let bytes = 0;
|
|
920
|
+
if (this.layout === undefined) {
|
|
921
|
+
for (const category of STORAGE_CATEGORIES) {
|
|
922
|
+
const categoryPath = join(this.options.runsRoot, category);
|
|
923
|
+
for (const name of (await readdir(categoryPath)).sort()) {
|
|
924
|
+
assertSafeComponent(name, 'storage entry');
|
|
925
|
+
const topInfo = await lstat(join(categoryPath, name));
|
|
926
|
+
if (!topInfo.isDirectory() || topInfo.isSymbolicLink())
|
|
927
|
+
throw unsafe(this.options.runsRoot, `${category} entry ${name} is not a directory`);
|
|
928
|
+
// The top-level address/transaction/run component is itself part of
|
|
929
|
+
// the shared recovery budget.
|
|
930
|
+
bytes += await inspectLocalTree(join(categoryPath, name), this.options.runsRoot, this.options.maxRecoveryEntries, count, this.options.maxRunStoreBytes, signal);
|
|
931
|
+
if (category === 'sessions' && !SESSION_ADDRESS.test(name))
|
|
932
|
+
throw unsafe(this.options.runsRoot, `invalid Session directory ${name}`);
|
|
933
|
+
if ((category === 'runs' || category === 'staging' || category === 'quarantine') && !COMPONENT32.test(name))
|
|
934
|
+
throw unsafe(this.options.runsRoot, `invalid ${category} entry ${name}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const sessionNames = (await readdir(join(this.options.runsRoot, 'sessions'))).sort();
|
|
938
|
+
for (const address of sessionNames) {
|
|
939
|
+
const directory = join(this.options.runsRoot, 'sessions', address);
|
|
940
|
+
const children = (await readdir(directory)).sort();
|
|
941
|
+
if (children.length !== 1 || children[0] !== 'manifest.json')
|
|
942
|
+
throw unsafe(this.options.runsRoot, `Session directory ${address} has an unexpected layout`);
|
|
943
|
+
const path = join(directory, 'manifest.json');
|
|
944
|
+
await ownerFile(path, this.options.maxManifestBytes);
|
|
945
|
+
const manifest = decodeWorkflowSessionManifest(await readBoundedUtf8(path, this.options.maxManifestBytes), path, this.options.maxManifestBytes);
|
|
946
|
+
if (sessionAddress(manifest.sessionId) !== address)
|
|
947
|
+
throw unsafe(this.options.runsRoot, `Session directory ${address} does not match its manifest identity`);
|
|
948
|
+
manifests.push(manifest);
|
|
949
|
+
}
|
|
950
|
+
for (const runDirectory of (await readdir(join(this.options.runsRoot, 'runs'))).sort()) {
|
|
951
|
+
runDirectories.add(runDirectory);
|
|
952
|
+
await this.validateLocalRunInventory(runDirectory, { details, detailDigests });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
for (const category of STORAGE_CATEGORIES) {
|
|
957
|
+
bytes += await inspectCapabilityTree(this.layout[category], this.options.runsRoot, this.options.maxRecoveryEntries, count, this.options.maxRunStoreBytes, signal);
|
|
958
|
+
}
|
|
959
|
+
const sessionEntries = await this.capabilityEntries(this.layout.sessions, signal);
|
|
960
|
+
for (const entry of sessionEntries) {
|
|
961
|
+
if (entry.type !== 'directory' || !SESSION_ADDRESS.test(entry.name))
|
|
962
|
+
throw unsafe(this.options.runsRoot, `invalid Session directory ${entry.name}`);
|
|
963
|
+
const directory = await this.layout.sessions.openDirectory(entry.name, signal, { create: false });
|
|
964
|
+
try {
|
|
965
|
+
const children = await this.capabilityEntries(directory, signal);
|
|
966
|
+
if (children.length !== 1 || children[0].name !== 'manifest.json' || children[0].type !== 'file')
|
|
967
|
+
throw unsafe(this.options.runsRoot, `Session directory ${entry.name} has an unexpected layout`);
|
|
968
|
+
const manifestBytes = await directory.readBytes('manifest.json', signal, this.options.maxManifestBytes);
|
|
969
|
+
await directory.assertIdentity(signal);
|
|
970
|
+
const path = join(this.options.runsRoot, 'sessions', entry.name, 'manifest.json');
|
|
971
|
+
const manifest = decodeWorkflowSessionManifest(manifestBytes, path, this.options.maxManifestBytes);
|
|
972
|
+
if (sessionAddress(manifest.sessionId) !== entry.name)
|
|
973
|
+
throw unsafe(this.options.runsRoot, `Session directory ${entry.name} does not match its manifest identity`);
|
|
974
|
+
manifests.push(manifest);
|
|
975
|
+
}
|
|
976
|
+
finally {
|
|
977
|
+
await directory.close().catch(() => undefined);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const runEntries = await this.capabilityEntries(this.layout.runs, signal);
|
|
981
|
+
for (const entry of runEntries) {
|
|
982
|
+
if (entry.type !== 'directory' || !COMPONENT32.test(entry.name))
|
|
983
|
+
throw unsafe(this.options.runsRoot, `invalid run entry ${entry.name}`);
|
|
984
|
+
runDirectories.add(entry.name);
|
|
985
|
+
await this.validateCapabilityRunInventory(entry.name, { details, detailDigests }, signal);
|
|
986
|
+
}
|
|
987
|
+
for (const category of ['staging', 'quarantine']) {
|
|
988
|
+
for (const entry of await this.capabilityEntries(this.layout[category], signal)) {
|
|
989
|
+
if (!COMPONENT32.test(entry.name))
|
|
990
|
+
throw unsafe(this.options.runsRoot, `invalid ${category} entry ${entry.name}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const referenced = new Set();
|
|
995
|
+
const referencedDetails = new Set();
|
|
996
|
+
for (const manifest of manifests) {
|
|
997
|
+
for (const head of manifest.heads) {
|
|
998
|
+
if (!runDirectories.has(head.runDirectory))
|
|
999
|
+
throw unsafe(this.options.runsRoot, `run directory ${head.runDirectory} is missing`);
|
|
1000
|
+
if (referenced.has(head.runDirectory))
|
|
1001
|
+
throw unsafe(this.options.runsRoot, `run directory ${head.runDirectory} is referenced more than once`);
|
|
1002
|
+
referenced.add(head.runDirectory);
|
|
1003
|
+
const key = `${head.runDirectory}/${head.detail.id}`;
|
|
1004
|
+
const detail = details.get(key);
|
|
1005
|
+
if (detail === undefined)
|
|
1006
|
+
throw unsafe(this.options.runsRoot, `detail ${head.detail.id} is missing`);
|
|
1007
|
+
if (referencedDetails.has(key))
|
|
1008
|
+
throw unsafe(this.options.runsRoot, `detail ${head.detail.id} is referenced more than once`);
|
|
1009
|
+
referencedDetails.add(key);
|
|
1010
|
+
if (detail.sessionId !== manifest.sessionId || detail.runId !== head.runId || detail.runDirectory !== head.runDirectory || detail.detailId !== head.detail.id || detail.snapshotRevision !== head.detail.snapshotRevision) {
|
|
1011
|
+
throw unsafe(this.options.runsRoot, `detail ${head.detail.id} identity does not match its manifest head`);
|
|
1012
|
+
}
|
|
1013
|
+
const digest = detailDigests.get(key);
|
|
1014
|
+
if (digest === undefined || digest.bytes !== head.detail.bytes || digest.sha256 !== head.detail.sha256) {
|
|
1015
|
+
throw unsafe(this.options.runsRoot, `detail ${head.detail.id} digest or byte count does not match its manifest head`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (count.value > this.options.maxRecoveryEntries)
|
|
1020
|
+
throw recoveryLimitError(this.options.runsRoot, this.options.maxRecoveryEntries);
|
|
1021
|
+
return { entries: count.value, bytes, manifests, runDirectories, details, detailDigests };
|
|
1022
|
+
}
|
|
1023
|
+
/** Remove only residue whose complete identity/shape was validated by the
|
|
1024
|
+
* preflight. A referenced run is retained; only its superseded immutable
|
|
1025
|
+
* detail snapshots are removed. */
|
|
1026
|
+
async reconcileRecoveryResidue(inventory, signal) {
|
|
1027
|
+
const referencedRuns = new Set();
|
|
1028
|
+
const referencedDetails = new Set();
|
|
1029
|
+
for (const manifest of inventory.manifests)
|
|
1030
|
+
for (const head of manifest.heads) {
|
|
1031
|
+
referencedRuns.add(head.runDirectory);
|
|
1032
|
+
referencedDetails.add(`${head.runDirectory}/${head.detail.id}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (this.layout === undefined) {
|
|
1035
|
+
for (const runDirectory of [...inventory.runDirectories].sort()) {
|
|
1036
|
+
signal?.throwIfAborted();
|
|
1037
|
+
if (!referencedRuns.has(runDirectory)) {
|
|
1038
|
+
await this.quarantineThenRemove('runs', runDirectory, signal);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const detailsPath = join(this.runPath(runDirectory), 'details');
|
|
1042
|
+
for (const name of (await readdir(detailsPath)).sort()) {
|
|
1043
|
+
const match = name.match(DETAIL_FILE);
|
|
1044
|
+
if (!match)
|
|
1045
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${name}`);
|
|
1046
|
+
if (referencedDetails.has(`${runDirectory}/${match[1]}`))
|
|
1047
|
+
continue;
|
|
1048
|
+
const path = join(detailsPath, name);
|
|
1049
|
+
const before = await lstat(path);
|
|
1050
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)
|
|
1051
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${name}`);
|
|
1052
|
+
const current = await lstat(path);
|
|
1053
|
+
if (!sameLocalIdentity(before, current))
|
|
1054
|
+
throw unsafe(this.options.runsRoot, `detail ${name} identity changed during recovery`);
|
|
1055
|
+
await unlink(path);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
for (const name of (await readdir(join(this.options.runsRoot, 'staging'))).sort()) {
|
|
1059
|
+
assertSafeComponent(name, 'storage entry');
|
|
1060
|
+
await this.quarantineThenRemove('staging', name, signal);
|
|
1061
|
+
}
|
|
1062
|
+
for (const name of (await readdir(join(this.options.runsRoot, 'quarantine'))).sort()) {
|
|
1063
|
+
assertSafeComponent(name, 'storage entry');
|
|
1064
|
+
await safeRemoveTree(join(this.options.runsRoot, 'quarantine', name));
|
|
1065
|
+
}
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
for (const runDirectory of [...inventory.runDirectories].sort()) {
|
|
1069
|
+
signal?.throwIfAborted();
|
|
1070
|
+
if (!referencedRuns.has(runDirectory)) {
|
|
1071
|
+
await this.quarantineThenRemove('runs', runDirectory, signal);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const run = await this.layout.runs.openDirectory(runDirectory, signal, { create: false });
|
|
1075
|
+
try {
|
|
1076
|
+
const details = await run.openDirectory('details', signal, { create: false });
|
|
1077
|
+
try {
|
|
1078
|
+
for (const entry of await this.capabilityEntries(details, signal)) {
|
|
1079
|
+
const match = entry.name.match(DETAIL_FILE);
|
|
1080
|
+
if (!match)
|
|
1081
|
+
throw unsafe(this.options.runsRoot, `unsafe detail entry ${entry.name}`);
|
|
1082
|
+
if (!referencedDetails.has(`${runDirectory}/${match[1]}`))
|
|
1083
|
+
await this.removeCapabilityFile(['runs', runDirectory, 'details'], entry.name, signal);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
await details.close().catch(() => undefined);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
finally {
|
|
1091
|
+
await run.close().catch(() => undefined);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
for (const entry of await this.capabilityEntries(this.layout.staging, signal)) {
|
|
1095
|
+
if (entry.type !== 'directory')
|
|
1096
|
+
throw unsafe(this.options.runsRoot, `staging entry ${entry.name} is not a directory`);
|
|
1097
|
+
await this.quarantineThenRemove('staging', entry.name, signal);
|
|
1098
|
+
}
|
|
1099
|
+
for (const entry of await this.capabilityEntries(this.layout.quarantine, signal)) {
|
|
1100
|
+
if (entry.type !== 'directory')
|
|
1101
|
+
throw unsafe(this.options.runsRoot, `quarantine entry ${entry.name} is not a directory`);
|
|
1102
|
+
await this.removeCapabilityTree('quarantine', entry.name, signal);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async initializeUnlocked(signal) {
|
|
1106
|
+
await this.guard(signal);
|
|
1107
|
+
if (this.initialized)
|
|
1108
|
+
return this.recovered;
|
|
1109
|
+
if (this.layout === undefined) {
|
|
1110
|
+
await ownerDirectory(this.options.runsRoot, true);
|
|
1111
|
+
for (const category of STORAGE_CATEGORIES)
|
|
1112
|
+
await ownerDirectory(join(this.options.runsRoot, category), true);
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
await this.layout.root.assertIdentity(signal);
|
|
1116
|
+
for (const category of STORAGE_CATEGORIES)
|
|
1117
|
+
await this.layout[category].assertIdentity(signal);
|
|
1118
|
+
}
|
|
1119
|
+
// This is the only read-only preflight. It inventories all four
|
|
1120
|
+
// categories, decodes every manifest/detail (including orphan residue),
|
|
1121
|
+
// and validates every retained identity before the first row mutation.
|
|
1122
|
+
const inventory = await this.validateRecoveryInventory(signal);
|
|
1123
|
+
await this.reconcileRecoveryResidue(inventory, signal);
|
|
1124
|
+
// Install the validated immutable observations only after preflight and
|
|
1125
|
+
// residue validation have succeeded. A malformed later Session can no
|
|
1126
|
+
// longer leave an earlier Session visible through this store instance.
|
|
1127
|
+
this.sessions.clear();
|
|
1128
|
+
this.details.clear();
|
|
1129
|
+
for (const manifest of inventory.manifests)
|
|
1130
|
+
this.sessions.set(manifest.sessionId, clone(manifest));
|
|
1131
|
+
for (const manifest of inventory.manifests)
|
|
1132
|
+
for (const head of manifest.heads) {
|
|
1133
|
+
const snapshot = inventory.details.get(`${head.runDirectory}/${head.detail.id}`);
|
|
1134
|
+
if (snapshot !== undefined)
|
|
1135
|
+
this.details.set(head.runId, clone(snapshot));
|
|
1136
|
+
}
|
|
1137
|
+
// Enforce the whole-store cap on the validated, residue-free state before
|
|
1138
|
+
// recovery can add replacement snapshots. This also handles stores that
|
|
1139
|
+
// were already over a lowered operator limit at process start.
|
|
1140
|
+
await this.enforceGlobalLimit(signal);
|
|
1141
|
+
const plans = [];
|
|
1142
|
+
const recoveryTime = now();
|
|
1143
|
+
for (const manifest of [...this.sessions.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId))) {
|
|
1144
|
+
const heads = [];
|
|
1145
|
+
const replacements = [];
|
|
1146
|
+
for (const original of manifest.heads) {
|
|
1147
|
+
let head = original;
|
|
1148
|
+
if (ACTIVE.has(original.status)) {
|
|
1149
|
+
const detail = inventory.details.get(`${original.runDirectory}/${original.detail.id}`);
|
|
1150
|
+
if (detail === undefined)
|
|
1151
|
+
throw unsafe(this.options.runsRoot, `detail ${original.detail.id} is missing during recovery planning`);
|
|
1152
|
+
const payload = {
|
|
1153
|
+
...detail.payload,
|
|
1154
|
+
members: (detail.payload.members ?? []).map(member => member.status === 'running'
|
|
1155
|
+
? { ...member, status: 'cancelled', outcome: member.outcome === 'pending' ? 'not-produced' : member.outcome, settledAt: recoveryTime }
|
|
1156
|
+
: member),
|
|
1157
|
+
};
|
|
1158
|
+
const replacement = { ...detail, detailId: id(), snapshotRevision: detail.snapshotRevision + 1, payload };
|
|
1159
|
+
const detailBytes = encodeWorkflowRunDetails(replacement, detailLimits(this.options));
|
|
1160
|
+
replacements.push({ old: original, snapshot: replacement, bytes: detailBytes });
|
|
1161
|
+
head = durableHead({
|
|
1162
|
+
...original,
|
|
1163
|
+
status: 'interrupted', stopReason: 'interrupted', error: 'Process exited before workflow settlement.', settledAt: recoveryTime,
|
|
1164
|
+
executionAvailable: false, saveAvailable: false, allowedActions: [], revision: original.revision + 1,
|
|
1165
|
+
detail: { id: replacement.detailId, bytes: detailBytes.byteLength, sha256: createHash('sha256').update(detailBytes).digest('hex'), snapshotRevision: replacement.snapshotRevision },
|
|
1166
|
+
detailRevision: replacement.snapshotRevision, membersRevision: original.membersRevision + 1,
|
|
1167
|
+
completionNotice: { state: 'abandoned', finalizedAt: recoveryTime, reason: 'process-lost' },
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
else if (original.completionNotice.state === 'claimed') {
|
|
1171
|
+
head = durableHead({ ...original, revision: original.revision + 1, completionNotice: { ...original.completionNotice, state: 'abandoned', finalizedAt: recoveryTime, reason: 'process-lost' } });
|
|
1172
|
+
}
|
|
1173
|
+
heads.push(head);
|
|
1174
|
+
}
|
|
1175
|
+
plans.push({ manifest, heads, replacements });
|
|
1176
|
+
}
|
|
1177
|
+
const recoveredRuns = [];
|
|
1178
|
+
for (const plan of plans) {
|
|
1179
|
+
signal?.throwIfAborted();
|
|
1180
|
+
for (const replacement of plan.replacements) {
|
|
1181
|
+
const text = new TextDecoder().decode(replacement.bytes);
|
|
1182
|
+
if (this.layout !== undefined)
|
|
1183
|
+
await this.writeCapabilityFile(['runs', replacement.old.runDirectory, 'details'], `${replacement.snapshot.detailId}.json`, text, this.options.maxRunDetailsBytes, true, signal);
|
|
1184
|
+
else
|
|
1185
|
+
await writeBoundedAtomic(join(this.runPath(replacement.old.runDirectory), 'details', `${replacement.snapshot.detailId}.json`), text, { maxBytes: this.options.maxRunDetailsBytes, createOnly: true });
|
|
1186
|
+
}
|
|
1187
|
+
if (plan.heads.some((head, index) => head !== plan.manifest.heads[index])) {
|
|
1188
|
+
await this.persist({ ...plan.manifest, revision: plan.manifest.revision + 1, heads: plan.heads });
|
|
1189
|
+
}
|
|
1190
|
+
for (const replacement of plan.replacements) {
|
|
1191
|
+
this.details.set(replacement.old.runId, clone(replacement.snapshot));
|
|
1192
|
+
// The manifest now points at the replacement. Remove the old file
|
|
1193
|
+
// only after that durable commit; a failure leaves harmless residue
|
|
1194
|
+
// for the next bounded recovery pass rather than a dangling reference.
|
|
1195
|
+
await this.removeCapabilityFile(['runs', replacement.old.runDirectory, 'details'], `${replacement.old.detail.id}.json`, signal).catch(error => {
|
|
1196
|
+
if (!isNotFound(error))
|
|
1197
|
+
throw error;
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
for (const head of plan.heads) {
|
|
1201
|
+
if (head.status === 'interrupted') {
|
|
1202
|
+
recoveredRuns.push({ ...head, executionAvailable: false, sessionId: plan.manifest.sessionId });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
await this.enforceGlobalLimit(signal);
|
|
1207
|
+
this.initialized = true;
|
|
1208
|
+
this.recovered = recoveredRuns;
|
|
1209
|
+
return this.recovered;
|
|
1210
|
+
}
|
|
1211
|
+
async initialize(signal) { return this.mutate(() => this.initializeUnlocked(signal)); }
|
|
1212
|
+
async ensureInitialized(signal) { if (!this.initialized)
|
|
1213
|
+
await this.initializeUnlocked(signal); }
|
|
1214
|
+
async evictForSession(manifest, extra) {
|
|
1215
|
+
let current = manifest;
|
|
1216
|
+
while (current.heads.length + extra > this.options.maxRetainedRunsPerSession) {
|
|
1217
|
+
const victim = current.heads.filter(head => terminal(head.status) && (head.completionNotice.state === 'delivered' || head.completionNotice.state === 'abandoned')).sort((a, b) => (a.settledAt ?? 0) - (b.settledAt ?? 0) || a.startedAt - b.startedAt || a.runId.localeCompare(b.runId))[0];
|
|
1218
|
+
if (!victim)
|
|
1219
|
+
throw new BoundedFileError(`workflow Session "${manifest.sessionId}" cannot retain another run`, 'WORKFLOW_STORAGE_LIMIT');
|
|
1220
|
+
current = { ...current, revision: current.revision + 1, heads: current.heads.filter(head => head.runId !== victim.runId) };
|
|
1221
|
+
await this.persist(current);
|
|
1222
|
+
await this.removeRunTree(victim.runDirectory).catch(error => { if (error.code !== 'ENOENT')
|
|
1223
|
+
throw error; });
|
|
1224
|
+
this.details.delete(victim.runId);
|
|
1225
|
+
}
|
|
1226
|
+
return current;
|
|
1227
|
+
}
|
|
1228
|
+
async enforceGlobalLimit(signal) {
|
|
1229
|
+
let bytes = await this.committedBytes(signal);
|
|
1230
|
+
while (bytes > this.options.maxRunStoreBytes) {
|
|
1231
|
+
signal?.throwIfAborted();
|
|
1232
|
+
const victim = [...this.sessions.values()].flatMap(manifest => manifest.heads.filter(head => terminal(head.status) && (head.completionNotice.state === 'delivered' || head.completionNotice.state === 'abandoned')).map(head => ({ manifest, head }))).sort((a, b) => (a.head.settledAt ?? 0) - (b.head.settledAt ?? 0) || a.head.startedAt - b.head.startedAt || a.head.runId.localeCompare(b.head.runId))[0];
|
|
1233
|
+
if (!victim)
|
|
1234
|
+
throw new BoundedFileError(`workflow run store exceeds the ${this.options.maxRunStoreBytes}-byte limit`, 'WORKFLOW_STORAGE_LIMIT');
|
|
1235
|
+
await this.persist({ ...victim.manifest, revision: victim.manifest.revision + 1, heads: victim.manifest.heads.filter(head => head.runId !== victim.head.runId) });
|
|
1236
|
+
await this.removeRunTree(victim.head.runDirectory, signal);
|
|
1237
|
+
this.details.delete(victim.head.runId);
|
|
1238
|
+
bytes = await this.committedBytes(signal);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Make the *next* insert fit before its run directory or manifest row is
|
|
1243
|
+
* published. The old implementation enforced the root cap only after the
|
|
1244
|
+
* durable callback, which could report an admitted run and then reject the
|
|
1245
|
+
* insertion. This preflight keeps the durable linearization point honest:
|
|
1246
|
+
* eligible terminal rows are evicted in the same deterministic order as
|
|
1247
|
+
* normal retention, and an impossible insert fails while it is still in
|
|
1248
|
+
* staging.
|
|
1249
|
+
*/
|
|
1250
|
+
async preflightTransactionCapacity(previous, candidate, initialFileBytes, signal) {
|
|
1251
|
+
if (!Number.isSafeInteger(initialFileBytes) || initialFileBytes < 0) {
|
|
1252
|
+
throw new BoundedFileError('workflow initial publication size is invalid', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
1253
|
+
}
|
|
1254
|
+
let next = candidate;
|
|
1255
|
+
for (;;) {
|
|
1256
|
+
signal?.throwIfAborted();
|
|
1257
|
+
const currentBytes = await this.committedBytes(signal);
|
|
1258
|
+
const nextBytes = encodeWorkflowSessionManifest(next, this.options.maxManifestBytes).byteLength;
|
|
1259
|
+
/*
|
|
1260
|
+
* The candidate is an insertion, not a replacement: the currently
|
|
1261
|
+
* published manifest remains in place while the new run is staged and
|
|
1262
|
+
* while the atomic manifest writer holds its temporary file. Charge
|
|
1263
|
+
* that peak, rather than only the eventual post-rename total. This is
|
|
1264
|
+
* deliberately conservative for a new Session as well and means a
|
|
1265
|
+
* caller never receives the durable callback for an insertion which
|
|
1266
|
+
* could only fit after a transient quota breach.
|
|
1267
|
+
*/
|
|
1268
|
+
const projectedPeak = currentBytes + initialFileBytes + nextBytes;
|
|
1269
|
+
if (projectedPeak <= this.options.maxRunStoreBytes)
|
|
1270
|
+
return next;
|
|
1271
|
+
const victim = [...this.sessions.values()]
|
|
1272
|
+
.flatMap(manifest => manifest.heads
|
|
1273
|
+
.filter(head => terminal(head.status) && (head.completionNotice.state === 'delivered' || head.completionNotice.state === 'abandoned'))
|
|
1274
|
+
.map(head => ({ manifest, head })))
|
|
1275
|
+
.sort((left, right) => (left.head.settledAt ?? 0) - (right.head.settledAt ?? 0)
|
|
1276
|
+
|| left.head.startedAt - right.head.startedAt
|
|
1277
|
+
|| left.head.runId.localeCompare(right.head.runId))[0];
|
|
1278
|
+
if (victim === undefined) {
|
|
1279
|
+
throw new BoundedFileError(`workflow run store exceeds the ${this.options.maxRunStoreBytes}-byte limit`, 'WORKFLOW_STORAGE_LIMIT');
|
|
1280
|
+
}
|
|
1281
|
+
const reduced = {
|
|
1282
|
+
...victim.manifest,
|
|
1283
|
+
revision: victim.manifest.revision + 1,
|
|
1284
|
+
heads: victim.manifest.heads.filter(head => head.runId !== victim.head.runId),
|
|
1285
|
+
};
|
|
1286
|
+
await this.persist(reduced);
|
|
1287
|
+
await this.removeRunTree(victim.head.runDirectory, signal);
|
|
1288
|
+
this.details.delete(victim.head.runId);
|
|
1289
|
+
if (victim.manifest.sessionId === previous.sessionId) {
|
|
1290
|
+
previous = reduced;
|
|
1291
|
+
next = {
|
|
1292
|
+
...next,
|
|
1293
|
+
// The eviction manifest was committed first. The insertion is the
|
|
1294
|
+
// next Session transaction and therefore must advance once more.
|
|
1295
|
+
revision: reduced.revision + 1,
|
|
1296
|
+
heads: next.heads.filter(head => head.runId !== victim.head.runId),
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async insertWithNextDisplayName(request, create, signal) {
|
|
1302
|
+
return this.mutate(async () => {
|
|
1303
|
+
await this.guard(signal);
|
|
1304
|
+
await this.ensureInitialized(signal);
|
|
1305
|
+
if (!isWorkflowDefinitionName(request.name))
|
|
1306
|
+
throw new BoundedFileError(`invalid workflow name "${request.name}"`, 'WORKFLOW_STORAGE_CORRUPT');
|
|
1307
|
+
const scriptBytes = encoder.encode(request.script);
|
|
1308
|
+
if (scriptBytes.byteLength > 1_048_576)
|
|
1309
|
+
throw new BoundedFileError('workflow script exceeds 1048576 bytes', 'WORKFLOW_STORAGE_LIMIT');
|
|
1310
|
+
let manifest = await this.load(request.sessionId);
|
|
1311
|
+
const ordinalEntry = manifest.ordinals.find(item => item.name === request.name);
|
|
1312
|
+
if (!ordinalEntry && manifest.ordinals.length >= this.options.maxWorkflowNamesPerSession)
|
|
1313
|
+
throw new BoundedFileError(`workflow Session has reached the ${this.options.maxWorkflowNamesPerSession}-name limit`, 'WORKFLOW_STORAGE_LIMIT');
|
|
1314
|
+
manifest = await this.evictForSession(manifest, 1);
|
|
1315
|
+
const { ordinal, displayName } = allocateDisplayIdentity(manifest, request.name);
|
|
1316
|
+
const numberedHandle = ordinal !== 1;
|
|
1317
|
+
const runDirectory = id();
|
|
1318
|
+
const identity = { displayName, numberedHandle, runDirectory };
|
|
1319
|
+
const draft = create(identity);
|
|
1320
|
+
const detailId = id();
|
|
1321
|
+
const detail = { version: 2, sessionId: request.sessionId, runId: request.runId, runDirectory, detailId, snapshotRevision: 1, payload: compactWorkflowRunDetails(draft.detail, detailLimits(this.options)) };
|
|
1322
|
+
const detailBytes = encodeWorkflowRunDetails(detail, detailLimits(this.options));
|
|
1323
|
+
const head = durableHead({ ...draft.head, runId: request.runId, name: request.name, displayName, numberedHandle, runDirectory, revision: 1, detail: { id: detailId, bytes: detailBytes.byteLength, sha256: createHash('sha256').update(detailBytes).digest('hex'), snapshotRevision: 1 }, detailRevision: 1, completionNotice: { state: 'none' }, executionAvailable: true });
|
|
1324
|
+
const ordinals = ordinalEntry ? manifest.ordinals.map(item => item.name === request.name ? { ...item, next: Math.max(item.next, ordinal + 1) } : item) : [...manifest.ordinals, { name: request.name, next: ordinal + 1 }];
|
|
1325
|
+
const candidate = {
|
|
1326
|
+
...manifest,
|
|
1327
|
+
revision: manifest.revision + 1,
|
|
1328
|
+
nextOrdinal: Math.max(manifest.nextOrdinal + 1, ordinal + 1),
|
|
1329
|
+
ordinals,
|
|
1330
|
+
heads: [...manifest.heads, head],
|
|
1331
|
+
};
|
|
1332
|
+
/*
|
|
1333
|
+
* Admission happens before creating a staging directory. In
|
|
1334
|
+
* particular, a global-quota rejection must not leave a published run
|
|
1335
|
+
* directory behind and must not invoke onDurable. preflight may evict
|
|
1336
|
+
* eligible retained rows, but it returns the manifest revision that the
|
|
1337
|
+
* initial insert must commit.
|
|
1338
|
+
*/
|
|
1339
|
+
const fitting = await this.preflightTransactionCapacity(manifest, candidate, scriptBytes.byteLength + detailBytes.byteLength, signal);
|
|
1340
|
+
const stageId = id();
|
|
1341
|
+
const stage = join(this.options.runsRoot, 'staging', stageId);
|
|
1342
|
+
const run = this.runPath(runDirectory);
|
|
1343
|
+
try {
|
|
1344
|
+
if (this.layout !== undefined) {
|
|
1345
|
+
const stageDirectory = await this.layout.staging.openDirectory(stageId, signal, { create: true });
|
|
1346
|
+
const scratchDirectory = await stageDirectory.openDirectory('scratch', signal, { create: true });
|
|
1347
|
+
const detailsDirectory = await stageDirectory.openDirectory('details', signal, { create: true });
|
|
1348
|
+
try {
|
|
1349
|
+
await stageDirectory.writeText('script.js', request.script, { kind: 'createIfAbsent' }, signal);
|
|
1350
|
+
await detailsDirectory.writeText(`${detailId}.json`, new TextDecoder().decode(detailBytes), { kind: 'createIfAbsent' }, signal);
|
|
1351
|
+
}
|
|
1352
|
+
finally {
|
|
1353
|
+
await detailsDirectory.close().catch(() => undefined);
|
|
1354
|
+
await scratchDirectory.close().catch(() => undefined);
|
|
1355
|
+
await stageDirectory.close().catch(() => undefined);
|
|
1356
|
+
}
|
|
1357
|
+
await this.layout.staging.assertIdentity(signal);
|
|
1358
|
+
await this.layout.runs.assertIdentity(signal);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
await ownerDirectory(stage, true);
|
|
1362
|
+
await ownerDirectory(join(stage, 'scratch'), true);
|
|
1363
|
+
await ownerDirectory(join(stage, 'details'), true);
|
|
1364
|
+
await writeBoundedAtomic(join(stage, 'script.js'), request.script, { maxBytes: 1_048_576, createOnly: true });
|
|
1365
|
+
await writeBoundedAtomic(join(stage, 'details', `${detailId}.json`), new TextDecoder().decode(detailBytes), { maxBytes: this.options.maxRunDetailsBytes, createOnly: true });
|
|
1366
|
+
}
|
|
1367
|
+
if (this.layout !== undefined) {
|
|
1368
|
+
// A Host capability must provide an atomic descriptor-rooted
|
|
1369
|
+
// publication primitive. Never fall back to `rename(stage, run)`
|
|
1370
|
+
// once descriptor-backed Host storage is active.
|
|
1371
|
+
if (this.layout.staging.publishDirectory === undefined) {
|
|
1372
|
+
throw new BoundedFileError('descriptor-rooted workflow directory publication is unavailable', 'WORKFLOW_STORAGE_UNSUPPORTED');
|
|
1373
|
+
}
|
|
1374
|
+
await this.layout.staging.publishDirectory(stageId, this.layout.runs, runDirectory, signal);
|
|
1375
|
+
await this.layout.staging.assertIdentity(signal);
|
|
1376
|
+
await this.layout.runs.assertIdentity(signal);
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
await rename(stage, run);
|
|
1380
|
+
}
|
|
1381
|
+
await this.persist(fitting);
|
|
1382
|
+
const published = exposedHead(head, this.options.runsRoot);
|
|
1383
|
+
// This is the durable linearization point. Invoke the supervisor's
|
|
1384
|
+
// callback before global accounting or any promise return so caller
|
|
1385
|
+
// cancellation can no longer own the admitted run.
|
|
1386
|
+
this.details.set(request.runId, detail);
|
|
1387
|
+
request.onDurable?.(published);
|
|
1388
|
+
return published;
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
if (this.layout !== undefined) {
|
|
1392
|
+
await this.removeStagingTree(stageId).catch(() => undefined);
|
|
1393
|
+
// The run may have been published before a later manifest/accounting
|
|
1394
|
+
// failure. Remove it only through the retained capability and only
|
|
1395
|
+
// when it is not referenced by the committed Session head.
|
|
1396
|
+
const current = this.sessions.get(request.sessionId);
|
|
1397
|
+
if (!current?.heads.some(candidate => candidate.runId === request.runId))
|
|
1398
|
+
await this.removeRunTree(runDirectory).catch(() => undefined);
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
await safeRemoveTree(stage).catch(() => undefined);
|
|
1402
|
+
await safeRemoveTree(run).catch(() => undefined);
|
|
1403
|
+
}
|
|
1404
|
+
throw error;
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
async commitRun(request, signal) {
|
|
1409
|
+
return this.mutate(async () => {
|
|
1410
|
+
await this.guard(signal);
|
|
1411
|
+
await this.ensureInitialized(signal);
|
|
1412
|
+
const manifest = await this.load(request.sessionId);
|
|
1413
|
+
const index = manifest.heads.findIndex(head => head.runId === request.runId);
|
|
1414
|
+
if (index < 0)
|
|
1415
|
+
throw new BoundedFileError('workflow run not found', 'WORKFLOW_RUN_NOT_FOUND');
|
|
1416
|
+
const old = manifest.heads[index];
|
|
1417
|
+
if (old.revision !== request.expectedRevision)
|
|
1418
|
+
throw new BoundedFileError('workflow run changed; refresh it before applying a control', 'WORKFLOW_STALE_REVISION');
|
|
1419
|
+
if (terminal(request.head.status))
|
|
1420
|
+
throw new BoundedFileError('terminal updates must claim a completion notice', 'WORKFLOW_STORAGE_CORRUPT');
|
|
1421
|
+
let reference = old.detail;
|
|
1422
|
+
let replacement;
|
|
1423
|
+
let replacementBytes;
|
|
1424
|
+
if (request.detail !== undefined) {
|
|
1425
|
+
replacement = { version: 2, sessionId: request.sessionId, runId: request.runId, runDirectory: old.runDirectory, detailId: id(), snapshotRevision: old.detailRevision + 1, payload: compactWorkflowRunDetails(request.detail, detailLimits(this.options)) };
|
|
1426
|
+
replacementBytes = encodeWorkflowRunDetails(replacement, detailLimits(this.options));
|
|
1427
|
+
reference = { id: replacement.detailId, bytes: replacementBytes.byteLength, sha256: createHash('sha256').update(replacementBytes).digest('hex'), snapshotRevision: replacement.snapshotRevision };
|
|
1428
|
+
}
|
|
1429
|
+
const head = durableHead({ ...request.head, runId: old.runId, name: old.name, displayName: old.displayName, numberedHandle: old.numberedHandle, runDirectory: old.runDirectory, revision: old.revision + 1, detail: reference, detailRevision: reference.snapshotRevision, completionNotice: old.completionNotice });
|
|
1430
|
+
const candidate = { ...manifest, revision: manifest.revision + 1, heads: manifest.heads.toSpliced(index, 1, head) };
|
|
1431
|
+
const fitting = await this.preflightTransactionCapacity(manifest, candidate, replacementBytes?.byteLength ?? 0, signal);
|
|
1432
|
+
let replacementWritten = false;
|
|
1433
|
+
try {
|
|
1434
|
+
if (replacement !== undefined && replacementBytes !== undefined) {
|
|
1435
|
+
if (this.layout !== undefined)
|
|
1436
|
+
await this.writeCapabilityFile(['runs', old.runDirectory, 'details'], `${replacement.detailId}.json`, new TextDecoder().decode(replacementBytes), this.options.maxRunDetailsBytes, true);
|
|
1437
|
+
else
|
|
1438
|
+
await writeBoundedAtomic(join(this.runPath(old.runDirectory), 'details', `${replacement.detailId}.json`), new TextDecoder().decode(replacementBytes), { maxBytes: this.options.maxRunDetailsBytes, createOnly: true });
|
|
1439
|
+
replacementWritten = true;
|
|
1440
|
+
}
|
|
1441
|
+
await this.persist(fitting);
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
if (replacementWritten && replacement !== undefined)
|
|
1445
|
+
await this.removeCapabilityFile(['runs', old.runDirectory, 'details'], `${replacement.detailId}.json`).catch(() => undefined);
|
|
1446
|
+
throw error;
|
|
1447
|
+
}
|
|
1448
|
+
if (replacement !== undefined) {
|
|
1449
|
+
this.details.set(request.runId, replacement);
|
|
1450
|
+
await this.removeCapabilityFile(['runs', old.runDirectory, 'details'], `${old.detail.id}.json`).catch(() => undefined);
|
|
1451
|
+
}
|
|
1452
|
+
await this.enforceGlobalLimit(signal);
|
|
1453
|
+
return exposedHead(head, this.options.runsRoot);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
async commitTerminalAndClaimNotice(request, signal) {
|
|
1457
|
+
return this.mutate(async () => {
|
|
1458
|
+
await this.guard(signal);
|
|
1459
|
+
await this.ensureInitialized(signal);
|
|
1460
|
+
const manifest = await this.load(request.sessionId);
|
|
1461
|
+
const index = manifest.heads.findIndex(head => head.runId === request.runId);
|
|
1462
|
+
if (index < 0)
|
|
1463
|
+
throw new BoundedFileError('workflow run not found', 'WORKFLOW_RUN_NOT_FOUND');
|
|
1464
|
+
const old = manifest.heads[index];
|
|
1465
|
+
if (old.revision !== request.expectedRevision)
|
|
1466
|
+
throw new BoundedFileError('workflow run changed; refresh it before applying a control', 'WORKFLOW_STALE_REVISION');
|
|
1467
|
+
if (!terminal(request.head.status))
|
|
1468
|
+
throw new BoundedFileError('terminal commit requires a terminal status', 'WORKFLOW_STORAGE_CORRUPT');
|
|
1469
|
+
let reference = old.detail;
|
|
1470
|
+
let replacement;
|
|
1471
|
+
let replacementBytes;
|
|
1472
|
+
if (request.detail !== undefined) {
|
|
1473
|
+
replacement = {
|
|
1474
|
+
version: 2,
|
|
1475
|
+
sessionId: request.sessionId,
|
|
1476
|
+
runId: request.runId,
|
|
1477
|
+
runDirectory: old.runDirectory,
|
|
1478
|
+
detailId: id(),
|
|
1479
|
+
snapshotRevision: old.detailRevision + 1,
|
|
1480
|
+
payload: compactWorkflowRunDetails(request.detail, detailLimits(this.options)),
|
|
1481
|
+
};
|
|
1482
|
+
replacementBytes = encodeWorkflowRunDetails(replacement, detailLimits(this.options));
|
|
1483
|
+
reference = {
|
|
1484
|
+
id: replacement.detailId,
|
|
1485
|
+
bytes: replacementBytes.byteLength,
|
|
1486
|
+
sha256: createHash('sha256').update(replacementBytes).digest('hex'),
|
|
1487
|
+
snapshotRevision: replacement.snapshotRevision,
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
const head = durableHead({
|
|
1491
|
+
...request.head,
|
|
1492
|
+
runId: old.runId,
|
|
1493
|
+
name: old.name,
|
|
1494
|
+
displayName: old.displayName,
|
|
1495
|
+
numberedHandle: old.numberedHandle,
|
|
1496
|
+
runDirectory: old.runDirectory,
|
|
1497
|
+
revision: old.revision + 1,
|
|
1498
|
+
detail: reference,
|
|
1499
|
+
detailRevision: reference.snapshotRevision,
|
|
1500
|
+
completionNotice: { state: 'claimed', claimId: id(), processEpoch: id(), claimedAt: now() },
|
|
1501
|
+
});
|
|
1502
|
+
const candidate = { ...manifest, revision: manifest.revision + 1, heads: manifest.heads.toSpliced(index, 1, head) };
|
|
1503
|
+
const fitting = await this.preflightTransactionCapacity(manifest, candidate, replacementBytes?.byteLength ?? 0, signal);
|
|
1504
|
+
let replacementWritten = false;
|
|
1505
|
+
try {
|
|
1506
|
+
if (replacement !== undefined && replacementBytes !== undefined) {
|
|
1507
|
+
if (this.layout !== undefined)
|
|
1508
|
+
await this.writeCapabilityFile(['runs', old.runDirectory, 'details'], `${replacement.detailId}.json`, new TextDecoder().decode(replacementBytes), this.options.maxRunDetailsBytes, true);
|
|
1509
|
+
else
|
|
1510
|
+
await writeBoundedAtomic(join(this.runPath(old.runDirectory), 'details', `${replacement.detailId}.json`), new TextDecoder().decode(replacementBytes), { maxBytes: this.options.maxRunDetailsBytes, createOnly: true });
|
|
1511
|
+
replacementWritten = true;
|
|
1512
|
+
}
|
|
1513
|
+
await this.persist(fitting);
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
if (replacementWritten && replacement !== undefined) {
|
|
1517
|
+
await this.removeCapabilityFile(['runs', old.runDirectory, 'details'], `${replacement.detailId}.json`).catch(() => undefined);
|
|
1518
|
+
}
|
|
1519
|
+
throw error;
|
|
1520
|
+
}
|
|
1521
|
+
if (replacement !== undefined) {
|
|
1522
|
+
this.details.set(request.runId, replacement);
|
|
1523
|
+
await this.removeCapabilityFile(['runs', old.runDirectory, 'details'], `${old.detail.id}.json`).catch(() => undefined);
|
|
1524
|
+
}
|
|
1525
|
+
await this.enforceGlobalLimit(signal);
|
|
1526
|
+
return exposedHead(head, this.options.runsRoot);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
async finalizeCompletionNotice(sessionId, runId, expectedRevision, finalization, signal) {
|
|
1530
|
+
return this.mutate(async () => {
|
|
1531
|
+
await this.guard(signal);
|
|
1532
|
+
await this.ensureInitialized(signal);
|
|
1533
|
+
const manifest = await this.load(sessionId);
|
|
1534
|
+
const index = manifest.heads.findIndex(head => head.runId === runId);
|
|
1535
|
+
if (index < 0)
|
|
1536
|
+
throw new BoundedFileError('workflow run not found', 'WORKFLOW_RUN_NOT_FOUND');
|
|
1537
|
+
const old = manifest.heads[index];
|
|
1538
|
+
if (old.revision !== expectedRevision)
|
|
1539
|
+
throw new BoundedFileError('workflow run changed; refresh it before applying a control', 'WORKFLOW_STALE_REVISION');
|
|
1540
|
+
if (old.completionNotice.state !== 'claimed')
|
|
1541
|
+
throw new BoundedFileError('completion notice is not claimable', 'WORKFLOW_STORAGE_CORRUPT');
|
|
1542
|
+
if (finalization.claimId !== old.completionNotice.claimId || finalization.processEpoch !== old.completionNotice.processEpoch || finalization.claimedAt !== old.completionNotice.claimedAt || finalization.finalizedAt < finalization.claimedAt)
|
|
1543
|
+
throw new BoundedFileError('completion notice claim does not match its owner', 'WORKFLOW_STALE_REVISION');
|
|
1544
|
+
const head = durableHead({ ...old, revision: old.revision + 1, completionNotice: finalization });
|
|
1545
|
+
const candidate = { ...manifest, revision: manifest.revision + 1, heads: manifest.heads.toSpliced(index, 1, head) };
|
|
1546
|
+
const fitting = await this.preflightTransactionCapacity(manifest, candidate, 0, signal);
|
|
1547
|
+
await this.persist(fitting);
|
|
1548
|
+
await this.enforceGlobalLimit(signal);
|
|
1549
|
+
return exposedHead(head, this.options.runsRoot);
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
async readSession(sessionId, signal) {
|
|
1553
|
+
return this.mutate(async () => { await this.guard(signal); await this.ensureInitialized(signal); return (await this.load(sessionId)).heads.map(head => clone(exposedHead(head, this.options.runsRoot))); });
|
|
1554
|
+
}
|
|
1555
|
+
async readDetails(runId, request, signal) {
|
|
1556
|
+
return this.mutate(async () => {
|
|
1557
|
+
await this.guard(signal);
|
|
1558
|
+
await this.ensureInitialized(signal);
|
|
1559
|
+
let found;
|
|
1560
|
+
for (const manifest of this.sessions.values()) {
|
|
1561
|
+
const head = manifest.heads.find(item => item.runId === runId);
|
|
1562
|
+
if (head) {
|
|
1563
|
+
found = { head, sessionId: manifest.sessionId };
|
|
1564
|
+
break;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (!found)
|
|
1568
|
+
throw new BoundedFileError('workflow run details not found', 'WORKFLOW_RUN_NOT_FOUND');
|
|
1569
|
+
const snapshot = await this.readDetail(found.head, found.sessionId);
|
|
1570
|
+
const payload = snapshot.payload;
|
|
1571
|
+
const raw = request.kind === 'members' ? payload.members ?? []
|
|
1572
|
+
: request.kind === 'logs' ? payload.logs ?? []
|
|
1573
|
+
: request.kind === 'result' ? payload.result ?? { state: 'not-produced' }
|
|
1574
|
+
: request.kind === 'phases' ? payload.phases ?? []
|
|
1575
|
+
: request.kind === 'artifacts' ? payload.artifacts ?? []
|
|
1576
|
+
: (payload.artifacts ?? []).find((item) => item.name === request.name) ?? null;
|
|
1577
|
+
const collection = Array.isArray(raw);
|
|
1578
|
+
let start = 0;
|
|
1579
|
+
if (request.cursor !== undefined) {
|
|
1580
|
+
if (!/^\d+$/u.test(request.cursor))
|
|
1581
|
+
throw new BoundedFileError('workflow detail cursor is invalid', 'WORKFLOW_STORAGE_CORRUPT');
|
|
1582
|
+
start = Number(request.cursor);
|
|
1583
|
+
if (!Number.isSafeInteger(start) || start < 0 || (collection && start > raw.length))
|
|
1584
|
+
throw new BoundedFileError('workflow detail cursor is invalid', 'WORKFLOW_STORAGE_CORRUPT');
|
|
1585
|
+
}
|
|
1586
|
+
const limit = request.limit ?? 200;
|
|
1587
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)
|
|
1588
|
+
throw new BoundedFileError('workflow detail page limit is invalid', 'WORKFLOW_STORAGE_LIMIT');
|
|
1589
|
+
const value = Array.isArray(raw) ? raw.slice(start, start + limit) : raw;
|
|
1590
|
+
const total = Array.isArray(raw) ? raw.length : raw === null ? 0 : 1;
|
|
1591
|
+
const omitted = request.kind === 'logs' && Array.isArray(raw) && raw.length > 0
|
|
1592
|
+
? Math.max(0, Number(raw[0]?.index) || 0)
|
|
1593
|
+
: 0;
|
|
1594
|
+
return {
|
|
1595
|
+
value: clone(value),
|
|
1596
|
+
revision: snapshot.snapshotRevision,
|
|
1597
|
+
total,
|
|
1598
|
+
...(omitted > 0 ? { omitted } : {}),
|
|
1599
|
+
...(Array.isArray(raw) && start + limit < raw.length ? { nextCursor: String(start + limit) } : {}),
|
|
1600
|
+
};
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
async dispose() { if (this.disposed)
|
|
1604
|
+
return; await this.mutationTail; this.disposed = true; this.sessions.clear(); this.details.clear(); }
|
|
1605
|
+
}
|
|
1606
|
+
//# sourceMappingURL=manifest-store.js.map
|