rootrework 1.0.2 → 1.0.3

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.
@@ -0,0 +1,324 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { pathToFileURL, fileURLToPath } from 'node:url';
6
+ import { getHeapStatistics } from 'node:v8';
7
+ import { CHUNK_BYTES, finished, fail, validateInput, integer, text, json, atomic, privateDir } from './process.mjs';
8
+
9
+ const moduleRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
10
+ const S = { type: 'string' }, I = { type: 'integer', minimum: 0 }, B = { type: 'boolean' };
11
+ const pageFields = { cursor: S, limit: I };
12
+ const artifactId = { artifact_id: S }, scheduleId = { schedule_id: S };
13
+ const command = { type: 'object', properties: { executable: S, args: { type: 'array', items: S }, shell: S, command: S, cwd: S, env: { type: 'object', additionalProperties: { type: ['string', 'null'] } }, stdin: S, stdin_encoding: { enum: ['utf8', 'base64'] }, timeout_ms: I }, additionalProperties: false };
14
+ const tool = (name, description, properties, required = []) => ({ name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false }, annotations: { readOnlyHint: /_(get|read|list|history)$/.test(name), destructiveHint: /_(delete|edit|write|update|pause|resume|finish)$/.test(name), openWorldHint: !/_(get|read|list|history)$/.test(name) } });
15
+ const schemas = [
16
+ tool('artifact_begin', 'Start a durable byte transfer. No destination is published until complete content is verified by artifact_finish. Reuse request_id after an uncertain response.', { size: I, name: S, destination: S, overwrite: B, expected_size: I, request_id: S }, ['size']),
17
+ tool('artifact_chunk', 'Upload a contiguous base64 chunk. Exact replay is accepted, gaps and changed overlap are rejected. Upload state survives agent reconnect.', { ...artifactId, offset: I, data: S }, ['artifact_id', 'offset', 'data']),
18
+ tool('artifact_capture', 'Retain a complete independent copy of a regular file. Source changes during capture fail rather than returning partial success.', { path: S, name: S, request_id: S }, ['path']),
19
+ tool('artifact_finish', 'Verify complete retained bytes and atomically publish to the selected native destination when supplied. A repeated finish validates the existing destination.', artifactId, ['artifact_id']),
20
+ tool('artifact_get', 'Inspect actual retained bytes, publication state and destination.', artifactId, ['artifact_id']),
21
+ tool('artifact_read', 'Retrieve retained bytes in bounded base64 chunks. Continue using next_offset until eof; no download URL or external service is required.', { ...artifactId, offset: I, length: I }, ['artifact_id']),
22
+ tool('artifact_list', 'List retained transfers with stable snapshot continuation.', pageFields),
23
+ tool('artifact_delete', 'Delete only retained transfer bytes and preserve its identity record. A previously published native destination is not deleted.', artifactId, ['artifact_id']),
24
+ tool('schedule_create', 'Create a durable once or interval schedule in UTC. The agent must be running. Missed runs may coalesce once, skip, or catch up each interval. Overlap may skip, wait, or allow. Calendar recurrence is unsupported. Commands are ordinary durable tasks.', { kind: { enum: ['once', 'interval'] }, at: S, interval_ms: I, timezone: { enum: ['UTC'] }, command, enabled: B, missed_run: { enum: ['run_once', 'skip', 'catch_up'] }, overlap: { enum: ['skip', 'wait', 'allow'] }, request_id: S }, ['kind', 'command']),
25
+ tool('schedule_get', 'Read the canonical schedule and last occurrence. Execution history is distinct from requested command effect.', scheduleId, ['schedule_id']),
26
+ tool('schedule_list', 'List durable schedules with stable snapshot continuation.', pageFields),
27
+ tool('schedule_update', 'Update future timing or commands with an optional revision guard. Created occurrences retain their frozen command and policy; prior history is retained.', { ...scheduleId, expected_revision: I, kind: { enum: ['once', 'interval'] }, at: S, interval_ms: I, timezone: { enum: ['UTC'] }, command, missed_run: { enum: ['run_once', 'skip', 'catch_up'] }, overlap: { enum: ['skip', 'wait', 'allow'] } }, ['schedule_id']),
28
+ tool('schedule_pause', 'Pause future occurrences without stopping an already running task.', { ...scheduleId, expected_revision: I }, ['schedule_id']),
29
+ tool('schedule_resume', 'Resume the existing schedule using its configured missed-run policy.', { ...scheduleId, expected_revision: I }, ['schedule_id']),
30
+ tool('schedule_history', 'Read occurrence records and actual linked task states. unknown_outcome is retained and never reported as success.', { ...scheduleId, ...pageFields }, ['schedule_id']),
31
+ tool('schedule_delete', 'Disable a task-owned schedule and retain its history. Existing processes are unaffected.', { ...scheduleId, expected_revision: I }, ['schedule_id'])
32
+ ];
33
+ const officeSchemas = [
34
+ tool('document_read', 'Read DOCX text including paragraphs, tables, headers and footers, with character continuation. Layout, images and tracked-change rendering are not advertised.', { path: S, offset: I, length: I }, ['path']),
35
+ tool('document_write', 'Create a DOCX containing supplied text paragraphs. Existing destinations require overwrite. Text and Unicode are preserved.', { path: S, paragraphs: { type: 'array', items: S }, text: S, overwrite: B }, ['path']),
36
+ tool('document_edit', 'Replace exact visible text in DOCX XML text runs, preserving other ZIP parts. Matches cannot cross paragraphs. Optional expected_text protects against concurrent changes.', { path: S, old_text: S, new_text: S, expected_occurrences: I, expected_text: S }, ['path', 'old_text', 'new_text']),
37
+ tool('spreadsheet_read', 'Read XLSX sheets and row/cell data, including stored formulas and cached values. Does not recalculate formulas. Continue using next_row.', { path: S, sheet: S, start_row: I, rows: I }, ['path']),
38
+ tool('spreadsheet_write', 'Write scalar values or formula records into an XLSX sheet. Existing workbook parts and untouched cells are preserved. Creates a workbook when absent; formulas are calculated by the consuming spreadsheet application.', { path: S, sheet: S, start_row: I, start_column: I, rows: { type: 'array', items: { type: 'array', items: { anyOf: [{ type: ['string', 'number', 'boolean', 'null'] }, { type: 'object', properties: { formula: S, value: { type: ['string', 'number', 'boolean', 'null'] } }, required: ['formula'], additionalProperties: false }] } } }, expected_size: I }, ['path', 'rows'])
39
+ ];
40
+ const pdfSchema = tool('pdf_read', 'Extract actual text from PDF pages with page and character continuation. Scanned images require OCR, which this tool does not claim. Password-protected documents may supply their own password.', { path: S, page: I, offset: I, length: I, password: S }, ['path']);
41
+
42
+ function id(value) { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) fail('INVALID_ARGUMENT', 'Invalid identifier.'); return value; }
43
+ function stable(v) { if (Array.isArray(v)) return '[' + v.map(stable).join(',') + ']'; if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stable(v[k])).join(',') + '}'; return JSON.stringify(v); }
44
+ async function exists(file) { try { return await fs.lstat(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
45
+ async function sameBytes(a, b) {
46
+ const x = await fs.open(a, 'r'), y = await fs.open(b, 'r');
47
+ try { const sx = await x.stat(), sy = await y.stat(); if (!sx.isFile() || !sy.isFile() || sx.size !== sy.size) return false;
48
+ for (let offset = 0; offset < sx.size; offset += CHUNK_BYTES) { const n = Math.min(CHUNK_BYTES, sx.size - offset), bx = Buffer.alloc(n), by = Buffer.alloc(n); const rx = await x.read(bx, 0, n, offset), ry = await y.read(by, 0, n, offset); if (rx.bytesRead !== n || ry.bytesRead !== n || !bx.equals(by)) return false; }
49
+ return true;
50
+ } finally { await x.close(); await y.close(); }
51
+ }
52
+ function cut(value, offset, length) { offset = integer(offset, 0); length = integer(length, 16000, 16000); if (!length) fail('INVALID_ARGUMENT', 'length must be positive.'); if (offset > value.length) fail('INVALID_OFFSET', 'Offset exceeds text length.'); let end = Math.min(value.length, offset + length); if (end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end--; return { text: value.slice(offset, end), offset, next_offset: end, total_characters: value.length, eof: end === value.length, truncated: false }; }
53
+ function date(value) { if (typeof value !== 'string' || !/(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(Date.parse(value))) fail('INVALID_ARGUMENT', 'A valid date with explicit timezone is required.'); return Date.parse(value); }
54
+ function xmlValue(value) { value = text(value); if (/[\u0001-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value)) fail('INVALID_ARGUMENT', 'Text contains characters unsupported by XML 1.0.'); return value; }
55
+ const xmlEscape = value => xmlValue(String(value)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
56
+ const namespaces = { w: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', s: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main', r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' };
57
+
58
+ export async function createExtensions({ stateDir, packageRoot = moduleRoot, deviceId, backend } = {}) {
59
+ if (!stateDir || !backend?.call) fail('INVALID_ARGUMENT', 'stateDir and backend are required.');
60
+ stateDir = path.resolve(stateDir);
61
+ const artifacts = path.join(stateDir, 'artifacts'), schedules = path.join(stateDir, 'schedules'), pages = path.join(stateDir, 'extension-pages'), requests = path.join(stateDir, 'extension-requests');
62
+ await Promise.all([privateDir(artifacts), privateDir(schedules), privateDir(pages), privateDir(requests)]);
63
+ const schedulerStartedAt = Date.now();
64
+ const scheduleRuntime = new Map();
65
+ let closed = false, timer, queue = Promise.resolve();
66
+ const locks = new Map();
67
+ const lock = async (key, fn) => { const next = (locks.get(key) || Promise.resolve()).catch(() => {}).then(fn); locks.set(key, next); try { return await next; } finally { if (locks.get(key) === next) locks.delete(key); } };
68
+ const serial = fn => { const p = queue.catch(() => {}).then(fn); queue = p; return p; };
69
+ const require = createRequire(path.join(packageRoot, 'package.json'));
70
+ let zip, dom, pdfPath;
71
+ try { zip = require('fflate'); dom = require('@xmldom/xmldom'); } catch { zip = dom = null; }
72
+ try { pdfPath = require.resolve('pdfjs-dist/legacy/build/pdf.mjs'); } catch {}
73
+ const tools = [...schemas, ...(zip && dom ? officeSchemas : []), ...(pdfPath ? [pdfSchema] : [])];
74
+ async function request(kind, args, action) {
75
+ if (args.request_id === undefined) return action();
76
+ text(args.request_id, 'request_id'); if (!args.request_id || Buffer.byteLength(args.request_id) > 128) fail('INVALID_ARGUMENT', 'request_id must contain 1 to 128 bytes.');
77
+ const file = path.join(requests, kind + '-' + Buffer.from(args.request_id).toString('base64url') + '.json');
78
+ return lock(file, async () => { const specification = stable(args); let saved; if (await exists(file)) { saved = await json(file); if (saved.specification !== specification) fail('REQUEST_CONFLICT', 'request_id identifies a different operation.'); if (saved.result) return { ...saved.result, reused: true }; }
79
+ else { saved = { specification, identity: randomUUID() }; await atomic(file, saved); }
80
+ const result = await action(saved.identity); await atomic(file, { ...saved, result }); return result;
81
+ });
82
+ }
83
+ async function paged(kind, entries, args) {
84
+ let key, offset;
85
+ if (args.cursor) { let c; try { c = JSON.parse(Buffer.from(args.cursor, 'base64url').toString()); } catch { fail('INVALID_CURSOR', 'Invalid continuation.'); } if (c.kind !== kind) fail('INVALID_CURSOR', 'Wrong continuation kind.'); key = id(c.id); offset = integer(c.offset); entries = await json(path.join(pages, key + '.json')); }
86
+ else { key = randomUUID(); offset = 0; await atomic(path.join(pages, key + '.json'), entries); }
87
+ const limit = integer(args.limit, 100, 1000); if (!limit) fail('INVALID_ARGUMENT', 'limit must be positive.');
88
+ let size = 0; const selected = []; for (const e of entries.slice(offset, offset + limit)) { const n = Buffer.byteLength(JSON.stringify(e)); if (selected.length && size + n > CHUNK_BYTES) break; selected.push(e); size += n; }
89
+ const next = offset + selected.length; return { entries: selected, total: entries.length, cursor: next < entries.length ? Buffer.from(JSON.stringify({ kind, id: key, offset: next })).toString('base64url') : null, truncated: false, snapshot: true };
90
+ }
91
+ const artifactDir = value => path.join(artifacts, id(value));
92
+ async function artifactGet(value) { const dir = artifactDir(value), m = await json(path.join(dir, 'state.json')); const s = await exists(path.join(dir, 'data.bin')); return { ...m, retained_bytes: s?.size || 0 }; }
93
+ async function artifactBegin(args, identity) {
94
+ const size = integer(args.size); const aid = identity || randomUUID(), dir = artifactDir(aid); await privateDir(dir);
95
+ if (await exists(path.join(dir, 'state.json'))) return artifactGet(aid);
96
+ if (!(await exists(path.join(dir, 'data.bin')))) await fs.writeFile(path.join(dir, 'data.bin'), '', { mode: 0o600, flag: 'wx' });
97
+ const m = { artifact_id: aid, device_id: deviceId, name: args.name === undefined ? aid : text(args.name), size, state: 'uploading', created_at: new Date().toISOString(), destination: args.destination === undefined ? null : path.resolve(text(args.destination)), overwrite: !!args.overwrite, ...(args.expected_size === undefined ? {} : { expected_size: integer(args.expected_size) }) }; await atomic(path.join(dir, 'state.json'), m); return { ...m, retained_bytes: 0 };
98
+ }
99
+ async function artifactChunk(args) { const dir = artifactDir(args.artifact_id); return lock(dir, async () => {
100
+ const m = await artifactGet(args.artifact_id); if (m.state !== 'uploading') fail('INVALID_STATE', 'Transfer is not accepting data.'); const offset = integer(args.offset), data = text(args.data);
101
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) fail('INVALID_ARGUMENT', 'Malformed base64.'); const bytes = Buffer.from(data, 'base64'); if (bytes.length > CHUNK_BYTES) fail('CHUNK_TOO_LARGE', `Use at most ${CHUNK_BYTES} bytes per chunk.`);
102
+ if (offset > m.retained_bytes || offset + bytes.length > m.size) fail('INVALID_OFFSET', 'Transfer chunks must be contiguous and within the declared size.');
103
+ const h = await fs.open(path.join(dir, 'data.bin'), 'r+'); let overlap = Math.min(bytes.length, m.retained_bytes - offset);
104
+ try { if (overlap) { const old = Buffer.alloc(overlap); const r = await h.read(old, 0, overlap, offset); if (r.bytesRead !== overlap || !old.equals(bytes.subarray(0, overlap))) fail('CONTENT_CONFLICT', 'Replayed bytes differ from retained content.'); }
105
+ let done = overlap; while (done < bytes.length) { const r = await h.write(bytes, done, bytes.length - done, offset + done); if (!r.bytesWritten) fail('WRITE_FAILED', 'No bytes were written.'); done += r.bytesWritten; } await h.sync();
106
+ } finally { await h.close(); }
107
+ return { artifact_id: m.artifact_id, retained_bytes: Math.max(m.retained_bytes, offset + bytes.length), size: m.size, replayed: overlap === bytes.length, next_offset: Math.max(m.retained_bytes, offset + bytes.length), complete: Math.max(m.retained_bytes, offset + bytes.length) === m.size };
108
+ }); }
109
+ async function artifactFinish(args) { const dir = artifactDir(args.artifact_id); return lock(dir, async () => {
110
+ let m = await artifactGet(args.artifact_id); if (m.state === 'deleted') fail('INVALID_STATE', 'Retained bytes were deleted.'); if (m.retained_bytes !== m.size) fail('INCOMPLETE_TRANSFER', 'Retained size differs from the declared complete size.'); const source = path.join(dir, 'data.bin');
111
+ if (m.destination) {
112
+ const previous = await exists(m.destination);
113
+ if (previous && !previous.isFile()) fail('NOT_FILE', 'Destination must be a regular file.');
114
+ if (previous && await sameBytes(source, m.destination)) { m.state = 'published'; }
115
+ else {
116
+ if (previous && !m.overwrite) fail('DESTINATION_EXISTS', 'A different destination exists.');
117
+ if (m.expected_size !== undefined && (previous?.size || 0) !== m.expected_size) fail('REVISION_CONFLICT', 'Destination size changed.');
118
+ const temp = path.join(path.dirname(m.destination), '.' + path.basename(m.destination) + '.' + randomUUID() + '.tmp');
119
+ try { await fs.copyFile(source, temp, 1); if (!(await sameBytes(source, temp))) fail('CONTENT_MISMATCH', 'Staged destination does not match retained bytes.'); const h = await fs.open(temp, 'r+'); try { await h.sync(); } finally { await h.close(); }
120
+ if (m.overwrite) await fs.rename(temp, m.destination); else { await fs.link(temp, m.destination); await fs.rm(temp); }
121
+ } catch (e) { await fs.rm(temp, { force: true }); throw e; }
122
+ if (!(await sameBytes(source, m.destination))) fail('CONTENT_MISMATCH', 'Published destination does not match retained bytes.'); m.state = 'published';
123
+ }
124
+ } else m.state = 'retained';
125
+ m.completed_at ||= new Date().toISOString(); delete m.retained_bytes; await atomic(path.join(dir, 'state.json'), m); return { ...m, retained_bytes: m.size, content_verified: true };
126
+ }); }
127
+ async function artifactCapture(args, identity) {
128
+ const source = path.resolve(text(args.path)), before = await fs.lstat(source); if (!before.isFile()) fail('NOT_FILE', 'Capture requires a regular file.'); const m = await artifactBegin({ size: before.size, name: args.name || path.basename(source) }, identity); if (m.state === 'retained') return m; const target = path.join(artifactDir(m.artifact_id), 'data.bin');
129
+ try { await fs.copyFile(source, target); const after = await fs.lstat(source); if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== after.ino || !(await sameBytes(source, target))) fail('SOURCE_CHANGED', 'Source changed while it was captured.'); return await artifactFinish(m); }
130
+ catch (e) { await fs.rm(artifactDir(m.artifact_id), { recursive: true, force: true }); throw e; }
131
+ }
132
+ async function artifactRead(args) { const m = await artifactGet(args.artifact_id); if (m.state === 'deleted') fail('INVALID_STATE', 'Retained bytes were deleted.'); const offset = integer(args.offset, 0), length = integer(args.length, CHUNK_BYTES, CHUNK_BYTES); if (offset > m.retained_bytes) fail('INVALID_OFFSET', 'Offset exceeds retained bytes.'); const h = await fs.open(path.join(artifactDir(m.artifact_id), 'data.bin'), 'r');
133
+ try { const b = Buffer.alloc(Math.min(length, m.retained_bytes - offset)), r = await h.read(b, 0, b.length, offset); return { artifact_id: m.artifact_id, data: b.subarray(0, r.bytesRead).toString('base64'), encoding: 'base64', offset, next_offset: offset + r.bytesRead, total_bytes: m.retained_bytes, eof: offset + r.bytesRead === m.retained_bytes, transfer_complete: m.retained_bytes === m.size, truncated: false }; } finally { await h.close(); }
134
+ }
135
+ const scheduleDir = value => path.join(schedules, id(value));
136
+ const publicSchedule = s => { const out = { ...s }; if (out.command?.env) out.command = { ...out.command, env: Object.fromEntries(Object.keys(out.command.env).map(k => [k, '[set]'])) }; return out; };
137
+ async function scheduleGet(value) { return json(path.join(scheduleDir(value), 'state.json')); }
138
+ function validateSchedule(input, previous = {}) {
139
+ if (input.timezone !== undefined && input.timezone !== 'UTC') fail('UNSUPPORTED_TIMEZONE', 'Only UTC once and interval schedules are supported.'); const s = { ...previous, ...input, timezone: 'UTC' }; delete s.expected_revision; delete s.request_id; delete s.schedule_id;
140
+ if (!['once', 'interval'].includes(s.kind)) fail('INVALID_ARGUMENT', 'kind must be once or interval.');
141
+ if (!['run_once', 'skip', 'catch_up'].includes(s.missed_run || 'run_once') || !['skip', 'wait', 'allow'].includes(s.overlap || 'skip')) fail('INVALID_ARGUMENT', 'Unsupported schedule policy.');
142
+ if (!s.command || typeof s.command !== 'object' || Array.isArray(s.command)) fail('INVALID_ARGUMENT', 'A command object is required.');
143
+ if (s.command.shell !== undefined) { text(s.command.shell); text(s.command.command); if (s.command.executable !== undefined || s.command.args !== undefined) fail('INVALID_ARGUMENT', 'Choose shell/command or executable/args.'); }
144
+ else { text(s.command.executable); if (s.command.command !== undefined || (s.command.args !== undefined && !Array.isArray(s.command.args))) fail('INVALID_ARGUMENT', 'Invalid executable arguments.'); for (const a of s.command.args || []) text(a); }
145
+ if (s.kind === 'interval') { s.interval_ms = integer(s.interval_ms); if (!s.interval_ms) fail('INVALID_ARGUMENT', 'interval_ms must be positive.'); } else delete s.interval_ms;
146
+ s.next_at = s.at === undefined ? (s.kind === 'interval' ? Date.now() + s.interval_ms : fail('INVALID_ARGUMENT', 'at is required for a once schedule.')) : date(s.at);
147
+ s.at = new Date(s.next_at).toISOString(); s.enabled = s.enabled !== false; s.missed_run ||= 'run_once'; s.overlap ||= 'skip'; return s;
148
+ }
149
+ async function scheduleCreate(args, identity) { const s = validateSchedule(args), sid = identity || randomUUID(); await privateDir(scheduleDir(sid)); await privateDir(path.join(scheduleDir(sid), 'occurrences')); if (await exists(path.join(scheduleDir(sid), 'state.json'))) return publicSchedule(await scheduleGet(sid)); const m = { ...s, schedule_id: sid, device_id: deviceId, revision: 1, created_at: new Date().toISOString() }; await atomic(path.join(scheduleDir(sid), 'state.json'), m); arm(); return publicSchedule(m); }
150
+ async function scheduleMutate(name, args) { const old = await scheduleGet(args.schedule_id); if (old.deleted_at) fail('INVALID_STATE', 'Schedule was deleted.'); if (args.expected_revision !== undefined && integer(args.expected_revision) !== old.revision) fail('REVISION_CONFLICT', 'Schedule revision changed.'); let s = old;
151
+ if (name === 'schedule_update') { s = { ...validateSchedule(args, old), schedule_id: old.schedule_id }; if (args.at === undefined && args.kind === undefined && args.interval_ms === undefined) s.next_at = old.next_at; else s.timing_updated_at = new Date().toISOString(); }
152
+ if (name === 'schedule_pause') s.enabled = false;
153
+ if (name === 'schedule_resume') { if (s.next_at === null && s.kind === 'once') fail('COMPLETED_SCHEDULE', 'Update the time before resuming a completed once schedule.'); s.enabled = true; s.resumed_at = new Date().toISOString(); if (s.next_at === null) s.next_at = Date.now() + s.interval_ms; }
154
+ if (name === 'schedule_delete') { s.enabled = false; s.deleted_at = new Date().toISOString(); }
155
+ s.revision = old.revision + 1; s.updated_at = new Date().toISOString(); await atomic(path.join(scheduleDir(s.schedule_id), 'state.json'), s); arm(); return publicSchedule(s);
156
+ }
157
+ async function dispatch(s, occurrence, file) {
158
+ try { const task = await backend.call('exec', { ...occurrence.command, request_id: occurrence.occurrence_id, wait_ms: 0 }); occurrence.task_id = task.task_id; occurrence.state = task.state; occurrence.dispatched_at = new Date().toISOString(); delete occurrence.command; }
159
+ catch (e) { occurrence.state = 'dispatch_failed'; occurrence.error = { code: e.code || 'EXEC_FAILED', message: e.message }; delete occurrence.command; }
160
+ await atomic(file, occurrence); return occurrence;
161
+ }
162
+ const occurrenceFile = (sid, oid) => path.join(scheduleDir(sid), 'occurrences', oid.replace(/:/g, '_') + '.json');
163
+ const policy = s => ({ kind: s.kind, interval_ms: s.interval_ms, missed_run: s.missed_run, overlap: s.overlap });
164
+ async function runtimeFor(s) {
165
+ if (scheduleRuntime.has(s.schedule_id)) return scheduleRuntime.get(s.schedule_id);
166
+ const runtime = { pending: new Map(), active: new Map(), waiting: false };
167
+ const dir = path.join(scheduleDir(s.schedule_id), 'occurrences');
168
+ for (const name of await fs.readdir(dir)) {
169
+ if (!name.endsWith('.json')) continue;
170
+ const file = path.join(dir, name), occurrence = await json(file);
171
+ if (occurrence.task_id && (!finished.has(occurrence.state) || occurrence.state === 'unknown_outcome')) runtime.active.set(occurrence.task_id, { file, occurrence });
172
+ else if (['pending', 'dispatching', 'waiting'].includes(occurrence.state)) runtime.pending.set(occurrence.occurrence_id, { file, occurrence });
173
+ }
174
+ scheduleRuntime.set(s.schedule_id, runtime);
175
+ return runtime;
176
+ }
177
+ async function refreshActive(runtime) {
178
+ for (const [taskId, entry] of runtime.active) {
179
+ let task;
180
+ try { task = await backend.call('task_get', { task_id: taskId }); }
181
+ catch (error) { task = { state: 'unknown_outcome', error: { code: error.code || 'TASK_UNAVAILABLE', message: 'The previous task outcome is unavailable.' } }; }
182
+ if (entry.occurrence.state !== task.state || entry.occurrence.exit_code !== task.exit_code) {
183
+ Object.assign(entry.occurrence, { state: task.state, exit_code: task.exit_code, signal: task.signal });
184
+ if (task.error) entry.occurrence.error = task.error;
185
+ await atomic(entry.file, entry.occurrence);
186
+ }
187
+ if (finished.has(task.state) && task.state !== 'unknown_outcome') runtime.active.delete(taskId);
188
+ }
189
+ }
190
+ async function consume(s, runtime, entry, now) {
191
+ const o = entry.occurrence, due = date(o.due_at), frozen = o.policy || policy(s);
192
+ o.policy ||= frozen;
193
+ const resolved = o.task_id || finished.has(o.state) || ['skipped', 'dispatch_failed'].includes(o.state);
194
+ if (!resolved && o.state !== 'dispatching') {
195
+ const missed = frozen.kind === 'interval' ? Math.max(0, Math.floor((now - due) / frozen.interval_ms)) : 0;
196
+ o.missed_intervals = missed; o.covered_intervals = frozen.missed_run === 'catch_up' ? 1 : missed + 1;
197
+ o.covered_until = new Date(due + (frozen.kind === 'interval' ? (o.covered_intervals - 1) * frozen.interval_ms : 0)).toISOString();
198
+ const missedBefore = Math.max(schedulerStartedAt, Date.parse(s.created_at), Date.parse(s.resumed_at || s.created_at), Date.parse(s.timing_updated_at || s.created_at));
199
+ let reason = frozen.missed_run === 'skip' && (missed > 0 || due < missedBefore) ? 'missed_run' : null;
200
+ if (!reason && runtime.active.size && frozen.overlap !== 'allow') {
201
+ if (frozen.overlap === 'wait') { o.state = 'waiting'; o.reason = 'previous_task_active_or_unknown'; runtime.waiting = true; await atomic(entry.file, o); return false; }
202
+ reason = 'previous_task_active_or_unknown';
203
+ }
204
+ if (reason) { o.state = 'skipped'; o.reason = reason; delete o.command; await atomic(entry.file, o); }
205
+ else { o.state = 'dispatching'; delete o.reason; await atomic(entry.file, o); }
206
+ }
207
+ if (!resolved && o.state === 'dispatching') await dispatch(s, o, entry.file);
208
+ if (o.task_id) { s.last_task_id = o.task_id; if (!finished.has(o.state) || o.state === 'unknown_outcome') runtime.active.set(o.task_id, entry); }
209
+ s.last_occurrence_id = o.occurrence_id;
210
+ if (s.next_at === due) {
211
+ const covered = o.covered_until ? date(o.covered_until) : due + (frozen.kind === 'interval' ? (o.missed_intervals || 0) * frozen.interval_ms : 0);
212
+ s.next_at = s.kind === 'interval' ? covered + s.interval_ms : null;
213
+ if (s.kind === 'once') s.enabled = false;
214
+ }
215
+ await atomic(path.join(scheduleDir(s.schedule_id), 'state.json'), s);
216
+ runtime.pending.delete(o.occurrence_id);
217
+ return true;
218
+ }
219
+ async function tick() {
220
+ if (closed) return; const now = Date.now();
221
+ for (const sid of await fs.readdir(schedules)) {
222
+ const s = await scheduleGet(sid), runtime = await runtimeFor(s);
223
+ runtime.waiting = false; await refreshActive(runtime);
224
+ if (!s.enabled || s.deleted_at) continue;
225
+ let entry = [...runtime.pending.values()].sort((a, b) => date(a.occurrence.due_at) - date(b.occurrence.due_at))[0];
226
+ if (!entry && s.next_at !== null && s.next_at <= now) {
227
+ const due = s.next_at, occurrenceId = `${sid}:${due}`, file = occurrenceFile(sid, occurrenceId);
228
+ let occurrence;
229
+ try { occurrence = await json(file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
230
+ occurrence ||= { occurrence_id: occurrenceId, schedule_id: sid, revision: s.revision, due_at: new Date(due).toISOString(), observed_at: new Date(now).toISOString(), state: 'pending', command: s.command, policy: policy(s) };
231
+ entry = { file, occurrence }; runtime.pending.set(occurrenceId, entry);
232
+ await atomic(file, occurrence);
233
+ }
234
+ if (entry) await consume(s, runtime, entry, now);
235
+ }
236
+ }
237
+ function arm(delay = 0) { if (closed) return; clearTimeout(timer); timer = setTimeout(() => serial(async () => {
238
+ try { await tick(); const due = []; for (const sid of await fs.readdir(schedules)) { const s = await scheduleGet(sid), runtime = scheduleRuntime.get(sid); if (s.enabled && !s.deleted_at) { if (runtime?.waiting) due.push(Date.now() + 250); else if (runtime?.pending.size) due.push(Date.now()); else if (s.next_at !== null) due.push(s.next_at); } } if (due.length) arm(Math.min(2147483647, Math.max(1, due.reduce((minimum, value) => Math.min(minimum, value), Infinity) - Date.now()))); }
239
+ catch (e) { await atomic(path.join(stateDir, 'scheduler-status.json'), { state: 'failed_recoverable', code: e.code || 'SCHEDULER_FAILED', message: e.message, at: new Date().toISOString() }).catch(() => {}); arm(1000); }
240
+ }), delay); timer.unref(); }
241
+
242
+ function parseXML(value) { if (/<!DOCTYPE|<!ENTITY/i.test(value)) fail('INVALID_DOCUMENT', 'External entities and DTDs are unsupported.'); const errors = []; const document = new dom.DOMParser({ onError(level, message) { if (level !== 'warning') errors.push(message); } }).parseFromString(value, 'application/xml'); if (errors.length || !document.documentElement) fail('INVALID_DOCUMENT', 'Malformed document XML.'); return document; }
243
+ const nodes = (document, ns, local) => Array.from(document.getElementsByTagNameNS(ns, local));
244
+ const serialize = document => new dom.XMLSerializer().serializeToString(document);
245
+ async function archive(file) { const budget = Math.floor(getHeapStatistics().heap_size_limit / 8); if ((await fs.stat(file)).size > budget) fail('RESOURCE_LIMIT', 'Document exceeds the available parser memory budget.'); const bytes = await fs.readFile(file); let size = 0; const parts = zip.unzipSync(bytes, { filter(entry) { size += entry.originalSize; if (size > budget) fail('RESOURCE_LIMIT', 'Expanded document exceeds the available parser memory budget.'); return true; } }); return { bytes, parts }; }
246
+ const partText = (parts, name) => { if (!parts[name]) fail('INVALID_DOCUMENT', `Required document part is absent: ${name}`); return new TextDecoder('utf-8', { fatal: true }).decode(parts[name]); };
247
+ function docxText(parts) { const values = []; for (const name of Object.keys(parts).filter(n => /^word\/(document|header\d*|footer\d*)\.xml$/.test(n)).sort((a, b) => a === 'word/document.xml' ? -1 : b === 'word/document.xml' ? 1 : a.localeCompare(b))) { const d = parseXML(partText(parts, name)); for (const p of nodes(d, namespaces.w, 'p')) values.push(nodes(p, namespaces.w, 't').map(n => n.textContent).join('')); } return values.join('\n'); }
248
+ async function writeDocument(file, bytes, previous, overwrite) { if (previous && !(await fs.readFile(file)).equals(previous)) fail('REVISION_CONFLICT', 'Document changed before publication.'); if (!previous && !overwrite && await exists(file)) fail('DESTINATION_EXISTS', 'Destination exists.'); const mode = (await exists(file))?.mode & 0o7777 || 0o600; if (previous || overwrite) await atomic(file, bytes, mode); else { const temp = file + '.' + randomUUID() + '.tmp'; try { await fs.writeFile(temp, bytes, { mode, flag: 'wx' }); await fs.link(temp, file); } finally { await fs.rm(temp, { force: true }); } } if (!(await fs.readFile(file)).equals(bytes)) fail('CONTENT_MISMATCH', 'Document readback failed.'); return { path: file, bytes: bytes.length, content_verified: true }; }
249
+ function zipBytes(parts) { return Buffer.from(zip.zipSync(parts, { level: 6 })); }
250
+ function docxParts(paragraphs) { const encode = value => new TextEncoder().encode(value); return {
251
+ '[Content_Types].xml': encode('<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'),
252
+ '_rels/.rels': encode('<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
253
+ 'word/document.xml': encode(`<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="${namespaces.w}"><w:body>${paragraphs.map(p => `<w:p><w:r><w:t xml:space="preserve">${xmlEscape(p)}</w:t></w:r></w:p>`).join('')}<w:sectPr/></w:body></w:document>`)
254
+ }; }
255
+ async function documentCall(name, args) { const file = path.resolve(text(args.path)); return lock(file, async () => {
256
+ if (name === 'document_write') { const paragraphs = args.paragraphs === undefined ? text(args.text).split(/\r?\n/) : args.paragraphs; if (!Array.isArray(paragraphs)) fail('INVALID_ARGUMENT', 'paragraphs must be an array.'); paragraphs.forEach(v => text(v)); return writeDocument(file, zipBytes(docxParts(paragraphs)), null, !!args.overwrite); }
257
+ const { bytes, parts } = await archive(file); const content = docxText(parts);
258
+ if (name === 'document_read') return { path: file, format: 'docx', ...cut(content, args.offset, args.length) };
259
+ if (args.expected_text !== undefined && text(args.expected_text) !== content) fail('REVISION_CONFLICT', 'Document text changed.'); const old = xmlValue(args.old_text), replacement = xmlValue(args.new_text); if (!old) fail('INVALID_ARGUMENT', 'old_text must not be empty.'); let count = 0;
260
+ for (const part of Object.keys(parts).filter(n => /^word\/(document|header\d*|footer\d*)\.xml$/.test(n))) { const d = parseXML(partText(parts, part)); let changed = false;
261
+ for (const p of nodes(d, namespaces.w, 'p')) { const runs = nodes(p, namespaces.w, 't'); const value = runs.map(n => n.textContent).join(''); const matches = []; for (let at = value.indexOf(old); at >= 0; at = value.indexOf(old, at + old.length)) matches.push(at); if (!matches.length) continue;
262
+ let starts = [], pos = 0; for (const n of runs) { starts.push(pos); pos += n.textContent.length; }
263
+ for (const at of matches.reverse()) { const end = at + old.length; for (let i = runs.length - 1; i >= 0; i--) { const a = starts[i], b = a + runs[i].textContent.length; if (b <= at || a >= end) continue; const localStart = Math.max(0, at - a), localEnd = Math.min(runs[i].textContent.length, end - a); runs[i].textContent = runs[i].textContent.slice(0, localStart) + (a <= at ? replacement : '') + runs[i].textContent.slice(localEnd); runs[i].setAttribute('xml:space', 'preserve'); } count++; changed = true; }
264
+ }
265
+ if (changed) parts[part] = new TextEncoder().encode(serialize(d));
266
+ }
267
+ if (!count || args.expected_occurrences !== undefined && count !== integer(args.expected_occurrences)) fail('MATCH_COUNT', 'Exact replacement count does not match.'); return { ...(await writeDocument(file, zipBytes(parts), bytes, true)), replacements: count };
268
+ }); }
269
+ function column(number) { let out = ''; for (number++; number; number = Math.floor((number - 1) / 26)) out = String.fromCharCode(65 + (number - 1) % 26) + out; return out; }
270
+ function cellIndex(ref) { const m = /^([A-Z]+)([1-9]\d*)$/.exec(ref); if (!m) fail('INVALID_DOCUMENT', 'Invalid cell reference.'); let col = 0; for (const c of m[1]) col = col * 26 + c.charCodeAt(0) - 64; return [Number(m[2]) - 1, col - 1]; }
271
+ function workbook(parts) { const d = parseXML(partText(parts, 'xl/workbook.xml')), relations = parseXML(partText(parts, 'xl/_rels/workbook.xml.rels')); const targets = new Map(Array.from(relations.documentElement.childNodes).filter(n => n.nodeType === 1).map(n => [n.getAttribute('Id'), n.getAttribute('Target')])); return nodes(d, namespaces.s, 'sheet').map(n => { const target = targets.get(n.getAttributeNS(namespaces.r, 'id') || n.getAttribute('r:id')); if (!target) fail('INVALID_DOCUMENT', 'Worksheet relationship is missing.'); const member = target.startsWith('/') ? target.slice(1) : path.posix.normalize('xl/' + target); if (!member.startsWith('xl/')) fail('INVALID_DOCUMENT', 'Worksheet target is outside its workbook.'); return { name: n.getAttribute('name'), member }; }); }
272
+ function xlsxParts(sheet) { const enc = s => new TextEncoder().encode(s); return { '[Content_Types].xml': enc('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>'), '_rels/.rels': enc('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'), 'xl/workbook.xml': enc(`<?xml version="1.0"?><workbook xmlns="${namespaces.s}" xmlns:r="${namespaces.r}"><sheets><sheet name="${xmlEscape(sheet)}" sheetId="1" r:id="rId1"/></sheets><calcPr fullCalcOnLoad="1"/></workbook>`), 'xl/_rels/workbook.xml.rels': enc('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>'), 'xl/worksheets/sheet1.xml': enc(`<?xml version="1.0"?><worksheet xmlns="${namespaces.s}"><sheetData/></worksheet>`) }; }
273
+ async function spreadsheetCall(name, args) { const file = path.resolve(text(args.path)); return lock(file, async () => {
274
+ let bytes = null, parts; const prior = await exists(file); if (prior) ({ bytes, parts } = await archive(file)); else if (name === 'spreadsheet_write') { const sheet = args.sheet || 'Sheet1'; if (typeof sheet !== 'string' || sheet.length > 31 || /[\[\]:*?/\\]|^'|'$/.test(sheet)) fail('INVALID_ARGUMENT', 'Invalid XLSX sheet name.'); parts = xlsxParts(sheet); } else fail('ENOENT', 'Workbook does not exist.');
275
+ if (args.expected_size !== undefined && integer(args.expected_size) !== (prior?.size || 0)) fail('REVISION_CONFLICT', 'Workbook size changed.'); const sheets = workbook(parts); const selected = args.sheet === undefined ? sheets[0] : sheets.find(s => s.name === args.sheet); if (!selected) fail('UNKNOWN_SHEET', 'Requested sheet does not exist.'); const d = parseXML(partText(parts, selected.member));
276
+ if (name === 'spreadsheet_read') {
277
+ const shared = parts['xl/sharedStrings.xml'] ? nodes(parseXML(partText(parts, 'xl/sharedStrings.xml')), namespaces.s, 'si').map(n => nodes(n, namespaces.s, 't').map(v => v.textContent).join('')) : [];
278
+ const start = integer(args.start_row, 0), limit = integer(args.rows, 100, 1000); if (!limit) fail('INVALID_ARGUMENT', 'rows must be positive.'); const all = nodes(d, namespaces.s, 'row'); const total = all.reduce((max, n) => Math.max(max, Number(n.getAttribute('r'))), 0); const output = []; let budget = 0, next = start;
279
+ for (let row = start; row < Math.min(total, start + limit); row++) { const n = all.find(n => Number(n.getAttribute('r')) === row + 1); const cells = []; if (n) for (const c of nodes(n, namespaces.s, 'c')) { const ref = c.getAttribute('r'), [, col] = cellIndex(ref), type = c.getAttribute('t'); const raw = nodes(c, namespaces.s, 'v')[0]?.textContent; const formula = nodes(c, namespaces.s, 'f')[0]?.textContent; let value = type === 'inlineStr' ? nodes(c, namespaces.s, 't').map(n => n.textContent).join('') : type === 's' ? shared[Number(raw)] : type === 'b' ? raw === '1' : type === 'str' || type === 'e' || type === 'd' ? raw : raw === undefined ? null : Number(raw); cells.push({ column: col, reference: ref, value: value ?? null, ...(formula === undefined ? {} : { formula }), ...(c.getAttribute('s') ? { style_index: Number(c.getAttribute('s')) } : {}) }); }
280
+ const record = { row, cells }, size = Buffer.byteLength(JSON.stringify(record)); if (budget + size > CHUNK_BYTES && output.length) break; if (size > CHUNK_BYTES) fail('ROW_TOO_LARGE', 'This row exceeds the response budget; use native file bytes to retrieve the workbook.'); output.push(record); budget += size; next = row + 1; }
281
+ return { path: file, sheet: selected.name, sheets: sheets.map(s => s.name), rows: output, next_row: next < total ? next : null, total_rows: total, recalculated: false, truncated: false };
282
+ }
283
+ if (!Array.isArray(args.rows) || args.rows.some(r => !Array.isArray(r))) fail('INVALID_ARGUMENT', 'rows must be an array of arrays.'); const r0 = integer(args.start_row, 0, 1048575), c0 = integer(args.start_column, 0, 16383); if (r0 + args.rows.length > 1048576 || args.rows.some(r => c0 + r.length > 16384)) fail('INVALID_ARGUMENT', 'Cell range exceeds the XLSX worksheet format.'); const data = nodes(d, namespaces.s, 'sheetData')[0]; if (!data) fail('INVALID_DOCUMENT', 'Worksheet data is missing.');
284
+ for (let r = 0; r < args.rows.length; r++) { const rowNumber = r0 + r + 1; let row = nodes(data, namespaces.s, 'row').find(n => Number(n.getAttribute('r')) === rowNumber); if (!row) { row = d.createElementNS(namespaces.s, 'row'); row.setAttribute('r', String(rowNumber)); const later = nodes(data, namespaces.s, 'row').find(n => Number(n.getAttribute('r')) > rowNumber); data.insertBefore(row, later || null); }
285
+ for (let c = 0; c < args.rows[r].length; c++) { const value = args.rows[r][c], ref = column(c0 + c) + rowNumber; let cell = nodes(row, namespaces.s, 'c').find(n => n.getAttribute('r') === ref); if (!cell) { cell = d.createElementNS(namespaces.s, 'c'); cell.setAttribute('r', ref); const later = nodes(row, namespaces.s, 'c').find(n => cellIndex(n.getAttribute('r'))[1] > c0 + c); row.insertBefore(cell, later || null); } while (cell.firstChild) cell.removeChild(cell.firstChild); cell.removeAttribute('t'); let scalar = value;
286
+ if (value && typeof value === 'object') { const formula = xmlValue(value.formula); const f = d.createElementNS(namespaces.s, 'f'); f.textContent = formula.replace(/^=/, ''); cell.appendChild(f); scalar = value.value ?? null; }
287
+ if (scalar === null) continue; if (!['string', 'number', 'boolean'].includes(typeof scalar) || typeof scalar === 'number' && !Number.isFinite(scalar)) fail('INVALID_ARGUMENT', 'Cells must be scalar values or formula records.');
288
+ if (typeof scalar === 'string') xmlValue(scalar);
289
+ if (typeof scalar === 'string' && !(value && typeof value === 'object')) { cell.setAttribute('t', 'inlineStr'); const is = d.createElementNS(namespaces.s, 'is'), t = d.createElementNS(namespaces.s, 't'); t.setAttribute('xml:space', 'preserve'); t.textContent = scalar; is.appendChild(t); cell.appendChild(is); }
290
+ else { if (typeof scalar === 'boolean') cell.setAttribute('t', 'b'); else if (typeof scalar === 'string') cell.setAttribute('t', 'str'); const v = d.createElementNS(namespaces.s, 'v'); v.textContent = String(typeof scalar === 'boolean' ? Number(scalar) : scalar); cell.appendChild(v); }
291
+ }
292
+ }
293
+ const cells = nodes(d, namespaces.s, 'c').map(n => cellIndex(n.getAttribute('r')));
294
+ const dimension = nodes(d, namespaces.s, 'dimension')[0]; if (dimension && cells.length) { const [firstRow, lastRow, firstCol, lastCol] = cells.reduce((v, [r, c]) => [Math.min(v[0], r), Math.max(v[1], r), Math.min(v[2], c), Math.max(v[3], c)], [Infinity, 0, Infinity, 0]); dimension.setAttribute('ref', `${column(firstCol)}${firstRow + 1}:${column(lastCol)}${lastRow + 1}`); }
295
+ const book = parseXML(partText(parts, 'xl/workbook.xml')); let calc = nodes(book, namespaces.s, 'calcPr')[0]; if (!calc) { calc = book.createElementNS(namespaces.s, 'calcPr'); book.documentElement.appendChild(calc); } calc.setAttribute('fullCalcOnLoad', '1'); calc.setAttribute('forceFullCalc', '1'); parts['xl/workbook.xml'] = new TextEncoder().encode(serialize(book));
296
+ if (parts['xl/calcChain.xml']) { delete parts['xl/calcChain.xml']; const rels = parseXML(partText(parts, 'xl/_rels/workbook.xml.rels')); for (const n of Array.from(rels.documentElement.childNodes)) if (n.nodeType === 1 && /\/calcChain$/.test(n.getAttribute('Type'))) n.parentNode.removeChild(n); parts['xl/_rels/workbook.xml.rels'] = new TextEncoder().encode(serialize(rels)); const types = parseXML(partText(parts, '[Content_Types].xml')); for (const n of Array.from(types.documentElement.childNodes)) if (n.nodeType === 1 && n.getAttribute('PartName') === '/xl/calcChain.xml') n.parentNode.removeChild(n); parts['[Content_Types].xml'] = new TextEncoder().encode(serialize(types)); }
297
+ parts[selected.member] = new TextEncoder().encode(serialize(d)); return { ...(await writeDocument(file, zipBytes(parts), bytes, !!bytes)), sheet: selected.name, rows_written: args.rows.length, recalculated: false };
298
+ }); }
299
+ async function pdfRead(args) { const file = path.resolve(text(args.path)), pdf = await import(pathToFileURL(pdfPath).href); const task = pdf.getDocument({ data: new Uint8Array(await fs.readFile(file)), password: args.password, useSystemFonts: true, isEvalSupported: false }); let document;
300
+ try { document = await task.promise; const page = integer(args.page, 0); if (page >= document.numPages) fail('INVALID_ARGUMENT', 'Page is outside the PDF.'); const p = await document.getPage(page + 1), content = await p.getTextContent(); const value = content.items.map(item => typeof item.str === 'string' ? item.str + (item.hasEOL ? '\n' : ' ') : '').join(''); const result = cut(value, args.offset, args.length); return { path: file, page, pages: document.numPages, ...result, next_page: result.eof && page + 1 < document.numPages ? page + 1 : null, extraction: value.trim() ? 'text' : 'no_text_layer' }; } finally { await task.destroy(); }
301
+ }
302
+ arm();
303
+ return { tools, capabilities: { artifacts: true, schedules: { timezone: 'UTC', kinds: ['once', 'interval'], missed_run: ['run_once', 'skip', 'catch_up'], overlap: ['skip', 'wait', 'allow'], requires_running_agent: true }, docx: !!zip, xlsx: !!zip, pdf_text: !!pdfPath }, async call(name, args = {}) {
304
+ if (closed) fail('CLOSED', 'Extensions are closed.'); if (!tools.some(t => t.name === name)) fail('UNKNOWN_TOOL', 'Tool is not available on this device.'); if (!args || typeof args !== 'object' || Array.isArray(args)) fail('INVALID_ARGUMENT', 'Arguments must be an object.');
305
+ if (name.startsWith('schedule_') && args.timezone !== undefined && args.timezone !== 'UTC') fail('UNSUPPORTED_TIMEZONE', 'This scheduler supports explicit UTC timing only.');
306
+ validateInput(args, tools.find(tool => tool.name === name).inputSchema);
307
+ if (name === 'artifact_begin') return request(name, args, identity => artifactBegin(args, identity));
308
+ if (name === 'artifact_capture') return request(name, args, identity => artifactCapture(args, identity));
309
+ if (name === 'artifact_chunk') return artifactChunk(args);
310
+ if (name === 'artifact_finish') return artifactFinish(args);
311
+ if (name === 'artifact_get') return artifactGet(args.artifact_id);
312
+ if (name === 'artifact_read') return artifactRead(args);
313
+ if (name === 'artifact_list') return paged('artifacts', args.cursor ? null : await Promise.all((await fs.readdir(artifacts)).sort().map(artifactGet)), args);
314
+ if (name === 'artifact_delete') return lock(artifactDir(args.artifact_id), async () => { const m = await artifactGet(args.artifact_id); await fs.rm(path.join(artifactDir(args.artifact_id), 'data.bin'), { force: true }); m.state = 'deleted'; m.deleted_at ||= new Date().toISOString(); delete m.retained_bytes; await atomic(path.join(artifactDir(args.artifact_id), 'state.json'), m); return m; });
315
+ if (name === 'schedule_create') return serial(() => request(name, args, identity => scheduleCreate(args, identity)));
316
+ if (name === 'schedule_get') return publicSchedule(await scheduleGet(args.schedule_id));
317
+ if (name === 'schedule_list') return paged('schedules', args.cursor ? null : await Promise.all((await fs.readdir(schedules)).sort().map(async sid => publicSchedule(await scheduleGet(sid)))), args);
318
+ if (['schedule_update', 'schedule_pause', 'schedule_resume', 'schedule_delete'].includes(name)) return serial(() => scheduleMutate(name, args));
319
+ if (name === 'schedule_history') { const dir = path.join(scheduleDir(args.schedule_id), 'occurrences'); const entries = args.cursor ? null : await Promise.all((await fs.readdir(dir)).filter(n => n.endsWith('.json')).sort().map(async n => { const o = await json(path.join(dir, n)); delete o.command; if (o.task_id) { try { const t = await backend.call('task_get', { task_id: o.task_id }); o.state = t.state; o.exit_code = t.exit_code; } catch (e) { o.state = 'unknown_outcome'; o.error = { code: e.code || 'TASK_UNAVAILABLE', message: 'Task outcome is unavailable.' }; } } return o; })); return paged('schedule-history-' + args.schedule_id, entries, args); }
320
+ if (name.startsWith('document_')) return documentCall(name, args);
321
+ if (name.startsWith('spreadsheet_')) return spreadsheetCall(name, args);
322
+ if (name === 'pdf_read') return pdfRead(args);
323
+ }, async close() { closed = true; clearTimeout(timer); await queue.catch(() => {}); await Promise.allSettled(locks.values()); } };
324
+ }
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs/promises';
2
+ import constants from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ export const CHUNK_BYTES = 64 * 1024;
9
+ export const finished = new Set(['completed_success', 'completed_failure', 'cancelled', 'timed_out', 'unknown_outcome']);
10
+ export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+ export function fail(code, message) { const e = new Error(message); e.code = code; throw e; }
12
+ export function validateInput(value, schema, label = 'arguments') {
13
+ const invalid = () => fail('INVALID_ARGUMENT', `${label} does not match the tool schema.`);
14
+ if (schema.anyOf) {
15
+ if (!schema.anyOf.some(option => { try { validateInput(value, option, label); return true; } catch { return false; } })) invalid();
16
+ return;
17
+ }
18
+ const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
19
+ if (schema.type && ![schema.type].flat().some(type => type === kind || type === 'integer' && Number.isSafeInteger(value))) invalid();
20
+ if (schema.enum && !schema.enum.includes(value)) invalid();
21
+ if (kind === 'number' && (!Number.isFinite(value) || schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) invalid();
22
+ if (kind === 'array' && schema.items) value.forEach((item, i) => validateInput(item, schema.items, `${label}[${i}]`));
23
+ if (kind === 'object') {
24
+ for (const field of schema.required || []) if (!Object.hasOwn(value, field)) invalid();
25
+ for (const [field, item] of Object.entries(value)) {
26
+ const child = schema.properties?.[field];
27
+ if (child) validateInput(item, child, `${label}.${field}`);
28
+ else if (schema.additionalProperties === false) invalid();
29
+ else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') validateInput(item, schema.additionalProperties, `${label}.${field}`);
30
+ }
31
+ }
32
+ }
33
+ export function integer(value, fallback, max = Number.MAX_SAFE_INTEGER) {
34
+ const n = value === undefined ? fallback : value;
35
+ if (!Number.isSafeInteger(n) || n < 0 || n > max) fail('INVALID_ARGUMENT', 'Expected a nonnegative integer within the supported range.');
36
+ return n;
37
+ }
38
+ export function text(value, label = 'value') {
39
+ if (typeof value !== 'string' || value.includes('\0') || !value.isWellFormed()) fail('INVALID_ARGUMENT', `${label} must be a valid Unicode string without NUL bytes.`);
40
+ return value;
41
+ }
42
+ export async function json(file) { return JSON.parse(await fs.readFile(file, 'utf8')); }
43
+ export async function atomic(file, value, mode = 0o600) {
44
+ const temp = `${file}.${randomUUID()}.tmp`;
45
+ const handle = await fs.open(temp, 'wx', mode);
46
+ try { await handle.writeFile(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)); await handle.sync(); }
47
+ finally { await handle.close(); }
48
+ const until = performance.now() + 5000;
49
+ let pause = 10;
50
+ try {
51
+ while (true) {
52
+ try { await fs.rename(temp, file); break; }
53
+ catch (error) {
54
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || performance.now() >= until) throw error;
55
+ await delay(Math.min(pause, Math.max(0, until - performance.now())));
56
+ pause = Math.min(pause * 2, 200);
57
+ }
58
+ }
59
+ } catch (error) { await fs.rm(temp, { force: true }).catch(() => {}); throw error; }
60
+ }
61
+ export async function privateDir(dir) { await fs.mkdir(dir, { recursive: true, mode: 0o700 }); }
62
+ export function envWith(changes = {}) {
63
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) fail('INVALID_ARGUMENT', 'env must be an object.');
64
+ const native = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'COMMONPROGRAMFILES', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'COLORTERM', 'LANG', 'LANGUAGE', 'TZ', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'DBUS_SESSION_BUS_ADDRESS']);
65
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => native.has(key.toUpperCase()) || /^LC_[A-Z_]+$/.test(key)));
66
+ for (const [key, value] of Object.entries(changes)) {
67
+ if (!key || key.includes('=') || key.includes('\0') || (value !== null && typeof value !== 'string')) fail('INVALID_ARGUMENT', 'Environment keys and values are invalid.');
68
+ if (process.platform === 'win32') for (const old of Object.keys(env)) if (old.toLowerCase() === key.toLowerCase()) delete env[old];
69
+ if (value === null) delete env[key]; else env[key] = text(value, 'environment value');
70
+ }
71
+ return env;
72
+ }
73
+ export async function executable(name, env = process.env) {
74
+ text(name, 'executable');
75
+ const candidate = name.includes('/') || name.includes('\\') ? [path.resolve(name)] : (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map(dir => path.join(dir, name));
76
+ const extensions = process.platform === 'win32' && !path.extname(name) ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
77
+ for (const file of candidate) for (const ext of extensions) {
78
+ const full = file + ext;
79
+ try { const s = await fs.stat(full); if (!s.isFile()) continue; await fs.access(full, process.platform === 'win32' ? constants.constants.F_OK : constants.constants.X_OK); return full; } catch {}
80
+ }
81
+ return null;
82
+ }
83
+ export async function shells() {
84
+ const names = process.platform === 'win32' ? ['pwsh', 'powershell', process.env.ComSpec, 'cmd'] : [process.env.SHELL, 'bash', 'zsh', 'sh', 'fish'];
85
+ const found = [];
86
+ for (const name of names.filter(Boolean)) { const file = await executable(name); if (file && !found.includes(file)) found.push(file); }
87
+ return found;
88
+ }
89
+ export function shellArgs(shell, command) {
90
+ const base = path.basename(shell).toLowerCase().replace(/\.exe$/, '');
91
+ if (base === 'cmd') return ['/d', '/s', '/c', command];
92
+ if (base === 'powershell' || base === 'pwsh') return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command];
93
+ return ['-c', command];
94
+ }
95
+ export async function processIdentity(pid) {
96
+ if (process.platform !== 'linux') return null;
97
+ try { const stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8'); const boot = await fs.readFile('/proc/sys/kernel/random/boot_id', 'utf8'); return boot.trim() + ':' + stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]; } catch { return null; }
98
+ }
99
+ export async function alive(pid, identity) {
100
+ if (!Number.isSafeInteger(pid) || pid < 1) return false;
101
+ try { process.kill(pid, 0); } catch (e) { return e.code === 'EPERM'; }
102
+ if (identity && process.platform === 'linux') return identity === await processIdentity(pid);
103
+ return true;
104
+ }
105
+ export async function killTree(pid, signal = 'SIGTERM') {
106
+ if (!Number.isSafeInteger(pid) || pid < 1) return;
107
+ if (process.platform === 'win32') {
108
+ const tool = await executable('taskkill');
109
+ if (!tool) fail('UNAVAILABLE', 'Native process-tree termination is unavailable.');
110
+ await new Promise((resolve, reject) => {
111
+ const p = spawn(tool, ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
112
+ p.once('error', reject); p.once('exit', () => resolve());
113
+ });
114
+ } else {
115
+ try { process.kill(-pid, signal); } catch (e) { if (e.code !== 'ESRCH') throw e; }
116
+ }
117
+ }
118
+ export async function platformInfo() {
119
+ let virtualization = null;
120
+ if (process.platform === 'linux') {
121
+ if (/microsoft/i.test(os.release()) || process.env.WSL_INTEROP) virtualization = 'wsl';
122
+ else { try { await fs.stat('/.dockerenv'); virtualization = 'container'; } catch {} }
123
+ }
124
+ return { platform: process.platform, architecture: process.arch, release: os.release(), hostname: os.hostname(), username: os.userInfo().username, home: os.homedir(), cwd: process.cwd(), node: process.version, executable: process.execPath, shells: await shells(), virtualization, pid: process.pid, uid: process.getuid?.() ?? null };
125
+ }