mecord-connect 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { OperatorError } from './errors.js';
|
|
7
|
+
import { createDurableStateBytes, readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
8
|
+
|
|
9
|
+
const MAX_TASK_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_LIST = 500;
|
|
11
|
+
const MAX_NODES = 5000;
|
|
12
|
+
const MAX_EVIDENCE = 10_000;
|
|
13
|
+
const MAX_FAILURES = 5000;
|
|
14
|
+
const MAX_SCOPE_ITEMS = 1000;
|
|
15
|
+
const MAX_CONDITIONS = 1000;
|
|
16
|
+
const TASK_OPTIONS = {
|
|
17
|
+
maxBytes: MAX_TASK_BYTES,
|
|
18
|
+
errorCode: 'TASK_STATE_CORRUPT',
|
|
19
|
+
invalidMessage: 'Stored task capsule is invalid.'
|
|
20
|
+
} ;
|
|
21
|
+
const TASK_STATES = new Set (['PENDING', 'RUNNING', 'PAUSED', 'CANCELLED', 'BLOCKED', 'FAILED', 'VERIFIED', 'SKIPPED']);
|
|
22
|
+
const MAX_ACTION_RECORDS = 5000;
|
|
23
|
+
const LEASE_OPTIONS = {
|
|
24
|
+
maxBytes: 16 * 1024,
|
|
25
|
+
errorCode: 'TASK_LEASE_CORRUPT',
|
|
26
|
+
invalidMessage: 'Stored task execution lease is invalid.'
|
|
27
|
+
} ;
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export class TaskStore {
|
|
45
|
+
#dir ;
|
|
46
|
+
#leaseDir ;
|
|
47
|
+
|
|
48
|
+
constructor(stateDir ) {
|
|
49
|
+
const root = path.resolve(stateDir);
|
|
50
|
+
this.#dir = path.join(root, 'tasks');
|
|
51
|
+
this.#leaseDir = path.join(root, 'task-leases');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async init() {
|
|
55
|
+
await fs.mkdir(this.#dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
const stat = await fs.lstat(this.#dir);
|
|
57
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
58
|
+
throw new OperatorError('TASK_STATE_CORRUPT', 'Task state directory must be a real directory, not a link or special file.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async put(taskInput ) {
|
|
63
|
+
await this.init();
|
|
64
|
+
const task = validateTaskCapsule(taskInput);
|
|
65
|
+
const file = this.#file(task.id);
|
|
66
|
+
await writeDurableStateText(file, JSON.stringify(task, null, 2), TASK_OPTIONS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async create(taskInput ) {
|
|
70
|
+
await this.init();
|
|
71
|
+
const task = validateTaskCapsule(taskInput);
|
|
72
|
+
const file = this.#file(task.id);
|
|
73
|
+
await createDurableStateBytes(file, Buffer.from(JSON.stringify(task, null, 2), 'utf8'), TASK_OPTIONS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get(taskIdInput ) {
|
|
77
|
+
await this.init();
|
|
78
|
+
const taskId = validTaskId(taskIdInput);
|
|
79
|
+
try {
|
|
80
|
+
return parseStoredTask(await readDurableStateText(this.#file(taskId), TASK_OPTIONS), taskId);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error ).code === 'ENOENT') {
|
|
83
|
+
throw new OperatorError('TASK_NOT_FOUND', `Task ${taskId} was not found.`);
|
|
84
|
+
}
|
|
85
|
+
if (error instanceof OperatorError) throw error;
|
|
86
|
+
throw new OperatorError('TASK_STATE_CORRUPT', `Stored task ${taskId} could not be read.`, { details: { cause: String(error) } });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async list(limitInput = 100) {
|
|
91
|
+
await this.init();
|
|
92
|
+
const parsedLimit = Number(limitInput);
|
|
93
|
+
const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(Math.trunc(parsedLimit), 1), MAX_LIST) : 100;
|
|
94
|
+
const entries = (await fs.readdir(this.#dir))
|
|
95
|
+
.filter((name) => name.endsWith('.json'))
|
|
96
|
+
.sort()
|
|
97
|
+
.slice(0, limit);
|
|
98
|
+
const tasks = [];
|
|
99
|
+
for (const name of entries) {
|
|
100
|
+
const candidateId = storedTaskIdFromFilename(name);
|
|
101
|
+
const task = parseStoredTask(
|
|
102
|
+
await readDurableStateText(path.join(this.#dir, name), TASK_OPTIONS),
|
|
103
|
+
candidateId
|
|
104
|
+
);
|
|
105
|
+
tasks.push(task);
|
|
106
|
+
}
|
|
107
|
+
return tasks
|
|
108
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
109
|
+
.map(({ id, userObjective, state, updatedAt }) => ({ id, userObjective, state, updatedAt }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async delete(taskIdInput ) {
|
|
113
|
+
await this.init();
|
|
114
|
+
const taskId = validTaskId(taskIdInput);
|
|
115
|
+
await fs.rm(this.#file(taskId), { force: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async acquireExecutionLease(taskIdInput ) {
|
|
119
|
+
const taskId = validTaskId(taskIdInput);
|
|
120
|
+
await this.#initLeaseDir();
|
|
121
|
+
const leasePath = path.join(this.#leaseDir, `${taskId}.json`);
|
|
122
|
+
const record = {
|
|
123
|
+
version: 1,
|
|
124
|
+
taskId,
|
|
125
|
+
ownerId: crypto.randomUUID(),
|
|
126
|
+
pid: process.pid,
|
|
127
|
+
acquiredAt: new Date().toISOString()
|
|
128
|
+
};
|
|
129
|
+
const serialized = Buffer.from(JSON.stringify(record), 'utf8');
|
|
130
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
await createDurableStateBytes(leasePath, serialized, LEASE_OPTIONS);
|
|
133
|
+
return taskExecutionLease(leasePath, record);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if ((error ).code !== 'EEXIST') throw error;
|
|
136
|
+
}
|
|
137
|
+
let existing ;
|
|
138
|
+
try { existing = await readTaskLease(leasePath, taskId); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
if ((error ).code === 'ENOENT') continue;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
if (processIsAlive(existing.pid)) {
|
|
144
|
+
throw new OperatorError('TASK_ALREADY_RUNNING', `Task ${taskId} is already owned by an active executor.`, {
|
|
145
|
+
details: { acquiredAt: existing.acquiredAt }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const stale = `${leasePath}.${crypto.randomUUID()}.stale`;
|
|
149
|
+
try {
|
|
150
|
+
await fs.rename(leasePath, stale);
|
|
151
|
+
await fs.rm(stale, { force: true });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!['ENOENT', 'EEXIST'].includes((error ).code ?? '')) throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw new OperatorError('TASK_ALREADY_RUNNING', `Task ${taskId} execution ownership changed concurrently.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#file(taskId ) {
|
|
160
|
+
return path.join(this.#dir, `${validTaskId(taskId)}.json`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #initLeaseDir() {
|
|
164
|
+
await fs.mkdir(this.#leaseDir, { recursive: true, mode: 0o700 });
|
|
165
|
+
const stat = await fs.lstat(this.#leaseDir);
|
|
166
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
167
|
+
throw new OperatorError('TASK_LEASE_CORRUPT', 'Task lease directory must be a real directory, not a link or special file.');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function taskExecutionLease(leasePath , expected ) {
|
|
173
|
+
let released = false;
|
|
174
|
+
const assertOwned = async () => {
|
|
175
|
+
if (released) throw new OperatorError('TASK_LEASE_LOST', 'Task execution lease has already been released.');
|
|
176
|
+
const current = await readTaskLease(leasePath, expected.taskId).catch((error) => {
|
|
177
|
+
if ((error ).code === 'ENOENT') throw new OperatorError('TASK_LEASE_LOST', 'Task execution lease no longer exists.');
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
if (current.ownerId !== expected.ownerId || current.pid !== expected.pid) {
|
|
181
|
+
throw new OperatorError('TASK_LEASE_LOST', 'Task execution lease ownership changed.');
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
taskId: expected.taskId,
|
|
186
|
+
ownerId: expected.ownerId,
|
|
187
|
+
assertOwned,
|
|
188
|
+
async release() {
|
|
189
|
+
if (released) return;
|
|
190
|
+
await assertOwned();
|
|
191
|
+
await fs.rm(leasePath);
|
|
192
|
+
await syncLeaseDirectory(path.dirname(leasePath));
|
|
193
|
+
released = true;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function readTaskLease(file , expectedTaskId ) {
|
|
199
|
+
let raw ;
|
|
200
|
+
try { raw = JSON.parse(await readDurableStateText(file, LEASE_OPTIONS)); }
|
|
201
|
+
catch (error) {
|
|
202
|
+
if ((error ).code === 'ENOENT') throw error;
|
|
203
|
+
if (error instanceof OperatorError) throw error;
|
|
204
|
+
throw new OperatorError('TASK_LEASE_CORRUPT', 'Stored task execution lease is not valid JSON.');
|
|
205
|
+
}
|
|
206
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new OperatorError('TASK_LEASE_CORRUPT', 'Stored task execution lease must be an object.');
|
|
207
|
+
const value = raw ;
|
|
208
|
+
if (value.version !== 1 || validLeaseId(value.taskId, 'taskId') !== expectedTaskId) throw new OperatorError('TASK_LEASE_CORRUPT', 'Stored task execution lease identity is invalid.');
|
|
209
|
+
const ownerId = validLeaseId(value.ownerId, 'ownerId');
|
|
210
|
+
const pid = Number(value.pid);
|
|
211
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fffffff) throw new OperatorError('TASK_LEASE_CORRUPT', 'Stored task execution lease PID is invalid.');
|
|
212
|
+
return { version: 1, taskId: expectedTaskId, ownerId, pid, acquiredAt: validIso(value.acquiredAt, 'lease acquiredAt') };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validLeaseId(input , label ) {
|
|
216
|
+
try { return validTaskId(String(input ?? '')); }
|
|
217
|
+
catch { throw new OperatorError('TASK_LEASE_CORRUPT', `Stored task execution lease ${label} is invalid.`); }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function processIsAlive(pid ) {
|
|
221
|
+
try { process.kill(pid, 0); return true; }
|
|
222
|
+
catch (error) {
|
|
223
|
+
const code = (error ).code;
|
|
224
|
+
if (code === 'ESRCH') return false;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function syncLeaseDirectory(directory ) {
|
|
230
|
+
if (process.platform === 'win32') return;
|
|
231
|
+
const handle = await fs.open(directory, 'r');
|
|
232
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseStoredTask(text , expectedTaskId ) {
|
|
236
|
+
let decoded ;
|
|
237
|
+
try {
|
|
238
|
+
decoded = JSON.parse(text);
|
|
239
|
+
} catch {
|
|
240
|
+
throw corrupt('Stored task capsule is not valid JSON.');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let task ;
|
|
244
|
+
try {
|
|
245
|
+
task = validateTaskCapsule(decoded);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof OperatorError && error.code === 'INVALID_TASK_ID') {
|
|
248
|
+
throw corrupt('Stored task capsule contains an invalid task or node id.');
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
if (task.id !== expectedTaskId) throw corrupt('Stored task ID does not match its state filename.');
|
|
253
|
+
return task;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function storedTaskIdFromFilename(name ) {
|
|
257
|
+
try {
|
|
258
|
+
return validTaskId(name.slice(0, -'.json'.length));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof OperatorError && error.code === 'INVALID_TASK_ID') {
|
|
261
|
+
throw corrupt('Task state directory contains a malformed task filename.');
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function validateTaskCapsule(input ) {
|
|
268
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw corrupt('Task capsule must be an object.');
|
|
269
|
+
const raw = input ;
|
|
270
|
+
const id = validTaskId(String(raw.id ?? ''));
|
|
271
|
+
const userObjective = boundedText(raw.userObjective, 256 * 1024, 'userObjective');
|
|
272
|
+
const interpretedObjective = boundedText(raw.interpretedObjective, 256 * 1024, 'interpretedObjective');
|
|
273
|
+
const authorizedScope = boundedTextArray(raw.authorizedScope, MAX_SCOPE_ITEMS, 4096, 'authorizedScope');
|
|
274
|
+
const prohibitedScope = boundedTextArray(raw.prohibitedScope, MAX_SCOPE_ITEMS, 4096, 'prohibitedScope');
|
|
275
|
+
const successConditions = boundedTextArray(raw.successConditions, MAX_CONDITIONS, 16_384, 'successConditions');
|
|
276
|
+
const state = validTaskState(raw.state, 'task state');
|
|
277
|
+
const nodes = validateNodes(raw.nodes);
|
|
278
|
+
const evidence = validateEvidenceArray(raw.evidence, MAX_EVIDENCE, 'task evidence');
|
|
279
|
+
const failures = validateFailures(raw.failures);
|
|
280
|
+
const execution = raw.execution === undefined ? undefined : validateExecution(raw.execution);
|
|
281
|
+
const createdAt = validIso(raw.createdAt, 'createdAt');
|
|
282
|
+
const updatedAt = validIso(raw.updatedAt, 'updatedAt');
|
|
283
|
+
if (Date.parse(updatedAt) < Date.parse(createdAt)) throw corrupt('Task updatedAt cannot precede createdAt.');
|
|
284
|
+
return {
|
|
285
|
+
id,
|
|
286
|
+
userObjective,
|
|
287
|
+
interpretedObjective,
|
|
288
|
+
authorizedScope,
|
|
289
|
+
prohibitedScope,
|
|
290
|
+
successConditions,
|
|
291
|
+
state,
|
|
292
|
+
nodes,
|
|
293
|
+
evidence,
|
|
294
|
+
failures,
|
|
295
|
+
...(execution ? { execution } : {}),
|
|
296
|
+
createdAt,
|
|
297
|
+
updatedAt
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateExecution(input ) {
|
|
302
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw corrupt('execution must be an object.');
|
|
303
|
+
const raw = input ;
|
|
304
|
+
if (raw.schemaVersion !== 1) throw corrupt('execution schemaVersion is invalid.');
|
|
305
|
+
const plannerId = boundedText(raw.plannerId, 256, 'execution plannerId');
|
|
306
|
+
const goalKind = boundedText(raw.goalKind, 256, 'execution goalKind');
|
|
307
|
+
const plannerState = jsonObject(raw.plannerState, 'execution plannerState');
|
|
308
|
+
const maxSteps = boundedInteger(raw.maxSteps, 1, 1000, 'execution maxSteps');
|
|
309
|
+
const maxAttemptsPerStep = boundedInteger(raw.maxAttemptsPerStep, 1, 20, 'execution maxAttemptsPerStep');
|
|
310
|
+
const timeoutMs = boundedInteger(raw.timeoutMs, 100, 24 * 60 * 60 * 1000, 'execution timeoutMs');
|
|
311
|
+
const stepCount = boundedInteger(raw.stepCount, 0, maxSteps, 'execution stepCount');
|
|
312
|
+
const startedAt = raw.startedAt === undefined ? undefined : validIso(raw.startedAt, 'execution startedAt');
|
|
313
|
+
const deadlineAt = raw.deadlineAt === undefined ? undefined : validIso(raw.deadlineAt, 'execution deadlineAt');
|
|
314
|
+
if ((startedAt === undefined) !== (deadlineAt === undefined)) throw corrupt('execution timing fields must appear together.');
|
|
315
|
+
if (startedAt && deadlineAt && Date.parse(deadlineAt) <= Date.parse(startedAt)) throw corrupt('execution deadline must follow start.');
|
|
316
|
+
if (!Array.isArray(raw.records) || raw.records.length > MAX_ACTION_RECORDS) throw corrupt(`execution records must contain at most ${MAX_ACTION_RECORDS} entries.`);
|
|
317
|
+
const records = raw.records.map((entry, index) => validateActionRecord(entry, index));
|
|
318
|
+
return { schemaVersion: 1, plannerId, goalKind, plannerState, maxSteps, maxAttemptsPerStep, timeoutMs, stepCount, ...(startedAt ? { startedAt, deadlineAt } : {}), records };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateActionRecord(input , index ) {
|
|
322
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw corrupt(`Action record ${index} must be an object.`);
|
|
323
|
+
const raw = input ;
|
|
324
|
+
const state = String(raw.state ?? '');
|
|
325
|
+
if (!['STARTED', 'SUCCEEDED', 'FAILED', 'BLOCKED', 'INTERRUPTED'].includes(state)) throw corrupt(`Action record ${index} state is invalid.`);
|
|
326
|
+
const inputHash = boundedText(raw.inputHash, 64, `action record ${index} inputHash`);
|
|
327
|
+
if (!/^[0-9a-f]{64}$/.test(inputHash)) throw corrupt(`Action record ${index} inputHash is invalid.`);
|
|
328
|
+
const risk = String(raw.risk ?? '');
|
|
329
|
+
if (!['read', 'write', 'external', 'system', 'destructive'].includes(risk)) throw corrupt(`Action record ${index} risk is invalid.`);
|
|
330
|
+
const startedAt = validIso(raw.startedAt, `action record ${index} startedAt`);
|
|
331
|
+
const finishedAt = raw.finishedAt === undefined ? undefined : validIso(raw.finishedAt, `action record ${index} finishedAt`);
|
|
332
|
+
if (state === 'STARTED' && finishedAt !== undefined) throw corrupt(`Action record ${index} cannot finish while STARTED.`);
|
|
333
|
+
if (state !== 'STARTED' && finishedAt === undefined) throw corrupt(`Action record ${index} must include finishedAt.`);
|
|
334
|
+
if (finishedAt && Date.parse(finishedAt) < Date.parse(startedAt)) throw corrupt(`Action record ${index} finishedAt cannot precede startedAt.`);
|
|
335
|
+
return {
|
|
336
|
+
stepKey: boundedText(raw.stepKey, 256, `action record ${index} stepKey`),
|
|
337
|
+
actionId: boundedText(raw.actionId, 256, `action record ${index} actionId`),
|
|
338
|
+
capability: boundedText(raw.capability, 256, `action record ${index} capability`),
|
|
339
|
+
risk: risk ,
|
|
340
|
+
inputHash,
|
|
341
|
+
attempt: boundedInteger(raw.attempt, 1, 20, `action record ${index} attempt`),
|
|
342
|
+
state: state ,
|
|
343
|
+
startedAt,
|
|
344
|
+
...(finishedAt === undefined ? {} : { finishedAt }),
|
|
345
|
+
...(raw.errorCode === undefined ? {} : { errorCode: boundedText(raw.errorCode, 256, `action record ${index} errorCode`) }),
|
|
346
|
+
...(raw.observation === undefined ? {} : { observation: validateObservation(raw.observation, index) }),
|
|
347
|
+
evidence: validateEvidenceArray(raw.evidence, MAX_EVIDENCE, `action record ${index} evidence`)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validateObservation(input , index ) {
|
|
352
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw corrupt(`Action record ${index} observation must be an object.`);
|
|
353
|
+
const raw = input ;
|
|
354
|
+
if (raw.schemaVersion !== 1 && raw.schemaVersion !== 2) throw corrupt(`Action record ${index} observation schemaVersion is invalid.`);
|
|
355
|
+
const channel = String(raw.channel ?? '');
|
|
356
|
+
if (channel !== 'semantic' && channel !== 'visual') throw corrupt(`Action record ${index} observation channel is invalid.`);
|
|
357
|
+
const domain = String(raw.domain ?? '');
|
|
358
|
+
if (!['project', 'filesystem', 'git', 'docker', 'database', 'ide', 'browser', 'uia', 'process', 'system', 'application', 'visual', 'unknown'].includes(domain)) {
|
|
359
|
+
throw corrupt(`Action record ${index} observation domain is invalid.`);
|
|
360
|
+
}
|
|
361
|
+
const provider = boundedText(raw.provider, 256, `action record ${index} observation provider`);
|
|
362
|
+
const observedAt = validIso(raw.observedAt, `action record ${index} observation observedAt`);
|
|
363
|
+
if (raw.schemaVersion === 1) {
|
|
364
|
+
return { schemaVersion: 1, channel, domain: domain , provider, observedAt };
|
|
365
|
+
}
|
|
366
|
+
const importantState = jsonObject(raw.importantState, `action record ${index} observation importantState`);
|
|
367
|
+
if (Buffer.byteLength(JSON.stringify(importantState), 'utf8') > 16 * 1024) throw corrupt(`Action record ${index} observation importantState is too large.`);
|
|
368
|
+
const confidence = Number(raw.confidence);
|
|
369
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw corrupt(`Action record ${index} observation confidence is invalid.`);
|
|
370
|
+
if (typeof raw.ambiguous !== 'boolean') throw corrupt(`Action record ${index} observation ambiguous must be boolean.`);
|
|
371
|
+
const evidenceRefs = boundedHashArray(raw.evidenceRefs, 100, `action record ${index} observation evidenceRefs`);
|
|
372
|
+
const stateVersion = boundedHash(raw.stateVersion, `action record ${index} observation stateVersion`);
|
|
373
|
+
return {
|
|
374
|
+
schemaVersion: 2, channel, domain: domain , provider,
|
|
375
|
+
capability: boundedText(raw.capability, 256, `action record ${index} observation capability`),
|
|
376
|
+
entityId: boundedText(raw.entityId, 256, `action record ${index} observation entityId`),
|
|
377
|
+
observedAt, stateVersion, importantState, ambiguous: raw.ambiguous, confidence, evidenceRefs
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateNodes(input ) {
|
|
382
|
+
if (!Array.isArray(input) || input.length > MAX_NODES) throw corrupt(`nodes must contain at most ${MAX_NODES} entries.`);
|
|
383
|
+
const ids = new Set ();
|
|
384
|
+
const nodes = input.map((entry, index) => {
|
|
385
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw corrupt(`Node ${index} must be an object.`);
|
|
386
|
+
const raw = entry ;
|
|
387
|
+
const id = validTaskId(String(raw.id ?? ''));
|
|
388
|
+
if (ids.has(id)) throw corrupt(`Node ${index} duplicates node id ${id}.`);
|
|
389
|
+
ids.add(id);
|
|
390
|
+
const title = boundedText(raw.title, 16_384, `node ${index} title`);
|
|
391
|
+
const state = validTaskState(raw.state, `node ${index} state`);
|
|
392
|
+
if (typeof raw.required !== 'boolean') throw corrupt(`Node ${index} required must be boolean.`);
|
|
393
|
+
const dependsOn = validateIdArray(raw.dependsOn, MAX_NODES, `node ${index} dependsOn`);
|
|
394
|
+
const evidence = validateEvidenceArray(raw.evidence, MAX_EVIDENCE, `node ${index} evidence`);
|
|
395
|
+
return { id, title, state, required: raw.required, dependsOn, evidence };
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
for (const node of nodes) {
|
|
399
|
+
for (const dependency of node.dependsOn) {
|
|
400
|
+
if (dependency === node.id || !ids.has(dependency)) throw corrupt(`Node ${node.id} has an invalid dependency ${dependency}.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
assertAcyclic(nodes);
|
|
404
|
+
return nodes;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validateEvidenceArray(input , max , label ) {
|
|
408
|
+
if (!Array.isArray(input) || input.length > max) throw corrupt(`${label} must contain at most ${max} entries.`);
|
|
409
|
+
return input.map((entry, index) => {
|
|
410
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw corrupt(`${label} entry ${index} must be an object.`);
|
|
411
|
+
const raw = entry ;
|
|
412
|
+
const kind = boundedText(raw.kind, 256, `${label} entry ${index} kind`);
|
|
413
|
+
const status = raw.status === 'pass' || raw.status === 'fail' || raw.status === 'info' ? raw.status : null;
|
|
414
|
+
if (!status) throw corrupt(`${label} entry ${index} status is invalid.`);
|
|
415
|
+
const message = boundedText(raw.message, 64 * 1024, `${label} entry ${index} message`);
|
|
416
|
+
const timestamp = validIso(raw.timestamp, `${label} entry ${index} timestamp`);
|
|
417
|
+
const data = raw.data === undefined ? undefined : jsonObject(raw.data, `${label} entry ${index} data`);
|
|
418
|
+
return { kind, status, message, ...(data ? { data } : {}), timestamp };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function validateFailures(input ) {
|
|
423
|
+
if (!Array.isArray(input) || input.length > MAX_FAILURES) throw corrupt(`failures must contain at most ${MAX_FAILURES} entries.`);
|
|
424
|
+
return input.map((entry, index) => {
|
|
425
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw corrupt(`Failure ${index} must be an object.`);
|
|
426
|
+
const raw = entry ;
|
|
427
|
+
return {
|
|
428
|
+
at: validIso(raw.at, `failure ${index} at`),
|
|
429
|
+
code: boundedText(raw.code, 256, `failure ${index} code`),
|
|
430
|
+
message: boundedText(raw.message, 64 * 1024, `failure ${index} message`)
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function assertAcyclic(nodes ) {
|
|
436
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
437
|
+
const visiting = new Set ();
|
|
438
|
+
const visited = new Set ();
|
|
439
|
+
const visit = (id ) => {
|
|
440
|
+
if (visited.has(id)) return;
|
|
441
|
+
if (visiting.has(id)) throw corrupt('Task dependency graph contains a cycle.');
|
|
442
|
+
visiting.add(id);
|
|
443
|
+
for (const dependency of byId.get(id)?.dependsOn ?? []) visit(dependency);
|
|
444
|
+
visiting.delete(id);
|
|
445
|
+
visited.add(id);
|
|
446
|
+
};
|
|
447
|
+
for (const node of nodes) visit(node.id);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function jsonObject(input , label ) {
|
|
451
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw corrupt(`${label} must be a JSON object.`);
|
|
452
|
+
let text ;
|
|
453
|
+
try { text = JSON.stringify(input); } catch { throw corrupt(`${label} must be JSON serializable.`); }
|
|
454
|
+
if (!text) throw corrupt(`${label} must be JSON serializable.`);
|
|
455
|
+
const parsed = JSON.parse(text) ;
|
|
456
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw corrupt(`${label} must remain an object after serialization.`);
|
|
457
|
+
return parsed ;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function boundedTextArray(input , maxItems , maxText , label ) {
|
|
461
|
+
if (!Array.isArray(input) || input.length > maxItems) throw corrupt(`${label} must contain at most ${maxItems} entries.`);
|
|
462
|
+
return input.map((value, index) => boundedText(value, maxText, `${label}[${index}]`));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function validateIdArray(input , maxItems , label ) {
|
|
466
|
+
if (!Array.isArray(input) || input.length > maxItems) throw corrupt(`${label} must contain at most ${maxItems} ids.`);
|
|
467
|
+
const ids = input.map((value) => validTaskId(String(value ?? '')));
|
|
468
|
+
if (new Set(ids).size !== ids.length) throw corrupt(`${label} contains duplicate ids.`);
|
|
469
|
+
return ids;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function boundedText(input , max , label ) {
|
|
473
|
+
if (typeof input !== 'string' || input.length < 1 || input.length > max || input.includes('\0')) throw corrupt(`${label} is invalid.`);
|
|
474
|
+
return input;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function boundedHash(input , label ) {
|
|
478
|
+
const value = boundedText(input, 64, label);
|
|
479
|
+
if (!/^[0-9a-f]{64}$/.test(value)) throw corrupt(`${label} must be a SHA-256 hex digest.`);
|
|
480
|
+
return value;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function boundedHashArray(input , maxItems , label ) {
|
|
484
|
+
if (!Array.isArray(input) || input.length > maxItems) throw corrupt(`${label} must contain at most ${maxItems} hashes.`);
|
|
485
|
+
return input.map((value, index) => boundedHash(value, `${label}[${index}]`));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function boundedInteger(input , min , max , label ) {
|
|
489
|
+
const value = Number(input);
|
|
490
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw corrupt(`${label} is invalid.`);
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function validIso(input , label ) {
|
|
495
|
+
const value = String(input ?? '');
|
|
496
|
+
const parsed = Date.parse(value);
|
|
497
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) throw corrupt(`${label} must be an ISO timestamp.`);
|
|
498
|
+
return value;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validTaskState(input , label ) {
|
|
502
|
+
const value = String(input ?? '') ;
|
|
503
|
+
if (!TASK_STATES.has(value)) throw corrupt(`${label} is invalid.`);
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function validTaskId(input ) {
|
|
508
|
+
const value = String(input ?? '');
|
|
509
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
510
|
+
throw new OperatorError('INVALID_TASK_ID', 'Invalid task id.');
|
|
511
|
+
}
|
|
512
|
+
return value.toLowerCase();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function corrupt(message ) {
|
|
516
|
+
return new OperatorError('TASK_STATE_CORRUPT', message);
|
|
517
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { OperatorError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
export function createTask(input ) {
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
return {
|
|
91
|
+
...input,
|
|
92
|
+
id: randomUUID(),
|
|
93
|
+
state: 'PENDING',
|
|
94
|
+
nodes: [],
|
|
95
|
+
evidence: [],
|
|
96
|
+
failures: [],
|
|
97
|
+
createdAt: now,
|
|
98
|
+
updatedAt: now
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function addTaskNode(task , title , options = {}) {
|
|
103
|
+
const node = {
|
|
104
|
+
id: randomUUID(),
|
|
105
|
+
title,
|
|
106
|
+
state: 'PENDING',
|
|
107
|
+
required: options.required ?? true,
|
|
108
|
+
dependsOn: options.dependsOn ?? [],
|
|
109
|
+
evidence: []
|
|
110
|
+
};
|
|
111
|
+
task.nodes.push(node);
|
|
112
|
+
task.updatedAt = new Date().toISOString();
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function setNodeState(task , nodeId , state ) {
|
|
117
|
+
const node = task.nodes.find((candidate) => candidate.id === nodeId);
|
|
118
|
+
if (!node) throw new OperatorError('TASK_NODE_NOT_FOUND', `Task node ${nodeId} was not found.`);
|
|
119
|
+
|
|
120
|
+
if (state === 'RUNNING') {
|
|
121
|
+
const blockedDependency = node.dependsOn
|
|
122
|
+
.map((id) => task.nodes.find((candidate) => candidate.id === id))
|
|
123
|
+
.find((dep) => !dep || dep.state !== 'VERIFIED');
|
|
124
|
+
if (blockedDependency) {
|
|
125
|
+
throw new OperatorError('TASK_DEPENDENCY_BLOCKED', `Node ${node.title} has an unverified dependency.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
node.state = state;
|
|
130
|
+
task.updatedAt = new Date().toISOString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function finalizeTask(task ) {
|
|
134
|
+
const incomplete = task.nodes.filter((node) => node.required && node.state !== 'VERIFIED' && node.state !== 'SKIPPED');
|
|
135
|
+
if (incomplete.length > 0) {
|
|
136
|
+
throw new OperatorError('TASK_NOT_VERIFIED', 'Required task nodes are incomplete.', {
|
|
137
|
+
details: { nodes: incomplete.map((node) => ({ id: node.id, title: node.title, state: node.state })) }
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
task.state = 'VERIFIED';
|
|
141
|
+
task.updatedAt = new Date().toISOString();
|
|
142
|
+
}
|