openworkgraph 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +64 -0
  2. package/dist/api-image-agent.d.ts +32 -0
  3. package/dist/api-image-agent.js +132 -0
  4. package/dist/api-image-output.d.ts +8 -0
  5. package/dist/api-image-output.js +41 -0
  6. package/dist/api.d.ts +35 -0
  7. package/dist/api.js +153 -0
  8. package/dist/auth.d.ts +58 -0
  9. package/dist/auth.js +273 -0
  10. package/dist/backend/adapter.d.ts +35 -0
  11. package/dist/backend/adapter.js +439 -0
  12. package/dist/backend/capabilities.d.ts +23 -0
  13. package/dist/backend/capabilities.js +75 -0
  14. package/dist/backend/diagnostics.d.ts +9 -0
  15. package/dist/backend/diagnostics.js +80 -0
  16. package/dist/backend/index.d.ts +6 -0
  17. package/dist/backend/index.js +7 -0
  18. package/dist/backend/models.d.ts +15 -0
  19. package/dist/backend/models.js +43 -0
  20. package/dist/backend/paths.d.ts +6 -0
  21. package/dist/backend/paths.js +69 -0
  22. package/dist/backend/stdio.d.ts +66 -0
  23. package/dist/backend/stdio.js +172 -0
  24. package/dist/backend/types.d.ts +90 -0
  25. package/dist/backend/types.js +11 -0
  26. package/dist/backup-api.d.ts +10 -0
  27. package/dist/backup-api.js +40 -0
  28. package/dist/blob-store.d.ts +40 -0
  29. package/dist/blob-store.js +218 -0
  30. package/dist/cli-help.d.ts +1 -0
  31. package/dist/cli-help.js +63 -0
  32. package/dist/cli-output.d.ts +4 -0
  33. package/dist/cli-output.js +130 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +284 -0
  36. package/dist/client-pairing.d.ts +8 -0
  37. package/dist/client-pairing.js +30 -0
  38. package/dist/directories.d.ts +14 -0
  39. package/dist/directories.js +34 -0
  40. package/dist/errors.d.ts +12 -0
  41. package/dist/errors.js +7 -0
  42. package/dist/events.d.ts +22 -0
  43. package/dist/events.js +155 -0
  44. package/dist/execution-chains.d.ts +24 -0
  45. package/dist/execution-chains.js +231 -0
  46. package/dist/execution-settings.d.ts +10 -0
  47. package/dist/execution-settings.js +38 -0
  48. package/dist/graph-document-history.d.ts +43 -0
  49. package/dist/graph-document-history.js +275 -0
  50. package/dist/graph-files.d.ts +14 -0
  51. package/dist/graph-files.js +59 -0
  52. package/dist/graph-transfer.d.ts +53 -0
  53. package/dist/graph-transfer.js +501 -0
  54. package/dist/graphs.d.ts +68 -0
  55. package/dist/graphs.js +771 -0
  56. package/dist/history.d.ts +20 -0
  57. package/dist/history.js +61 -0
  58. package/dist/http.d.ts +2 -0
  59. package/dist/http.js +42 -0
  60. package/dist/image-credentials.d.ts +11 -0
  61. package/dist/image-credentials.js +130 -0
  62. package/dist/image-providers.d.ts +51 -0
  63. package/dist/image-providers.js +271 -0
  64. package/dist/input-materialization.d.ts +17 -0
  65. package/dist/input-materialization.js +95 -0
  66. package/dist/inputs.d.ts +74 -0
  67. package/dist/inputs.js +301 -0
  68. package/dist/local-control.d.ts +13 -0
  69. package/dist/local-control.js +37 -0
  70. package/dist/network.d.ts +11 -0
  71. package/dist/network.js +48 -0
  72. package/dist/openai-image-driver.d.ts +42 -0
  73. package/dist/openai-image-driver.js +368 -0
  74. package/dist/operations/backups.d.ts +40 -0
  75. package/dist/operations/backups.js +391 -0
  76. package/dist/operations/capacity.d.ts +7 -0
  77. package/dist/operations/capacity.js +35 -0
  78. package/dist/operations/cli-context.d.ts +7 -0
  79. package/dist/operations/cli-context.js +58 -0
  80. package/dist/operations/daemon.d.ts +36 -0
  81. package/dist/operations/daemon.js +159 -0
  82. package/dist/operations/files.d.ts +12 -0
  83. package/dist/operations/files.js +84 -0
  84. package/dist/operations/lifecycle.d.ts +28 -0
  85. package/dist/operations/lifecycle.js +155 -0
  86. package/dist/operations/local-admin.d.ts +6 -0
  87. package/dist/operations/local-admin.js +38 -0
  88. package/dist/operations/logs.d.ts +16 -0
  89. package/dist/operations/logs.js +37 -0
  90. package/dist/operations/plugins.d.ts +10 -0
  91. package/dist/operations/plugins.js +11 -0
  92. package/dist/operations/snapshot-worker.d.ts +1 -0
  93. package/dist/operations/snapshot-worker.js +18 -0
  94. package/dist/operations/snapshot.d.ts +1 -0
  95. package/dist/operations/snapshot.js +14 -0
  96. package/dist/operations/update.d.ts +26 -0
  97. package/dist/operations/update.js +216 -0
  98. package/dist/persistence/asset-business-schema.d.ts +2 -0
  99. package/dist/persistence/asset-business-schema.js +36 -0
  100. package/dist/persistence/database.d.ts +7 -0
  101. package/dist/persistence/database.js +91 -0
  102. package/dist/persistence/plugin-schema.d.ts +9 -0
  103. package/dist/persistence/plugin-schema.js +38 -0
  104. package/dist/persistence/repositories.d.ts +20 -0
  105. package/dist/persistence/repositories.js +106 -0
  106. package/dist/persistence/resource-schema.d.ts +2 -0
  107. package/dist/persistence/resource-schema.js +44 -0
  108. package/dist/persistence/schema.d.ts +6 -0
  109. package/dist/persistence/schema.js +236 -0
  110. package/dist/persistence/workflow-schema.d.ts +2 -0
  111. package/dist/persistence/workflow-schema.js +19 -0
  112. package/dist/plugin-api.d.ts +10 -0
  113. package/dist/plugin-api.js +40 -0
  114. package/dist/plugin-management.d.ts +36 -0
  115. package/dist/plugin-management.js +112 -0
  116. package/dist/plugins.d.ts +55 -0
  117. package/dist/plugins.js +239 -0
  118. package/dist/project-discovery.d.ts +3 -0
  119. package/dist/project-discovery.js +57 -0
  120. package/dist/project-files-api.d.ts +16 -0
  121. package/dist/project-files-api.js +243 -0
  122. package/dist/project-files.d.ts +97 -0
  123. package/dist/project-files.js +557 -0
  124. package/dist/project-lock.d.ts +4 -0
  125. package/dist/project-lock.js +35 -0
  126. package/dist/project-probe-worker.d.ts +1 -0
  127. package/dist/project-probe-worker.js +17 -0
  128. package/dist/project-probe.d.ts +22 -0
  129. package/dist/project-probe.js +67 -0
  130. package/dist/projects.d.ts +29 -0
  131. package/dist/projects.js +219 -0
  132. package/dist/publication.d.ts +31 -0
  133. package/dist/publication.js +642 -0
  134. package/dist/resource-api.d.ts +15 -0
  135. package/dist/resource-api.js +256 -0
  136. package/dist/resources-orphans.d.ts +41 -0
  137. package/dist/resources-orphans.js +235 -0
  138. package/dist/resources.d.ts +246 -0
  139. package/dist/resources.js +714 -0
  140. package/dist/run-api.d.ts +10 -0
  141. package/dist/run-api.js +308 -0
  142. package/dist/run-context.d.ts +54 -0
  143. package/dist/run-context.js +137 -0
  144. package/dist/runs.d.ts +82 -0
  145. package/dist/runs.js +506 -0
  146. package/dist/runtime-backend.d.ts +24 -0
  147. package/dist/runtime-backend.js +214 -0
  148. package/dist/runtime.d.ts +97 -0
  149. package/dist/runtime.js +553 -0
  150. package/dist/scheduler.d.ts +85 -0
  151. package/dist/scheduler.js +344 -0
  152. package/dist/service.d.ts +29 -0
  153. package/dist/service.js +515 -0
  154. package/dist/skills-api.d.ts +18 -0
  155. package/dist/skills-api.js +120 -0
  156. package/dist/storage-api.d.ts +21 -0
  157. package/dist/storage-api.js +92 -0
  158. package/dist/transfer-api.d.ts +10 -0
  159. package/dist/transfer-api.js +60 -0
  160. package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
  161. package/dist/vendor/protocol/contracts.typecheck.js +35 -0
  162. package/dist/vendor/protocol/execution-chain.d.ts +6 -0
  163. package/dist/vendor/protocol/execution-chain.js +26 -0
  164. package/dist/vendor/protocol/graph-binary.d.ts +15 -0
  165. package/dist/vendor/protocol/graph-binary.js +121 -0
  166. package/dist/vendor/protocol/graph-title.d.ts +2 -0
  167. package/dist/vendor/protocol/graph-title.js +23 -0
  168. package/dist/vendor/protocol/index.d.ts +617 -0
  169. package/dist/vendor/protocol/index.js +20 -0
  170. package/dist/vendor/protocol/pairing.d.ts +29 -0
  171. package/dist/vendor/protocol/pairing.js +71 -0
  172. package/dist/vendor/protocol/preview.d.ts +2 -0
  173. package/dist/vendor/protocol/preview.js +9 -0
  174. package/dist/vendor/protocol/resource-mime.d.ts +2 -0
  175. package/dist/vendor/protocol/resource-mime.js +35 -0
  176. package/dist/video-thumbnail.d.ts +2 -0
  177. package/dist/video-thumbnail.js +34 -0
  178. package/package.json +9 -0
@@ -0,0 +1,391 @@
1
+ import { DatabaseSync, backup } from 'node:sqlite';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join, sep } from 'node:path';
4
+ import { lstat, mkdtemp, rename, rm, realpath, open } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { MIGRATIONS, SCHEMA_VERSION } from '../persistence/schema.js';
7
+ import { atomic, migrate, transaction } from '../persistence/database.js';
8
+ import { Repositories } from '../persistence/repositories.js';
9
+ import { digest, identifier, privateDir, readSafe, safePath, syncDir, writeNew } from './files.js';
10
+ import { createDatabaseSnapshot } from './snapshot.js';
11
+ import { ServiceError } from '../errors.js';
12
+ const MAX_ARCHIVE = 512 * 1024 * 1024;
13
+ const pendingBackups = new WeakMap();
14
+ export async function waitForBackups(db) {
15
+ while (pendingBackups.get(db)?.size)
16
+ await Promise.allSettled([...pendingBackups.get(db)]);
17
+ }
18
+ /** Call BEFORE starting runtime/HTTP, only while holding exclusive root ownership. */
19
+ export function clearStaleResourceLeases(db, lock) {
20
+ const file = db.prepare('PRAGMA database_list').all().find(row => row.name === 'main')?.file;
21
+ if (!lock.held || file !== join(lock.root, 'service.sqlite'))
22
+ throw new Error('Lease recovery requires matching exclusive root ownership');
23
+ transaction(db, () => {
24
+ for (const table of ['resource_read_leases', 'resource_maintenance_leases'])
25
+ if (hasTable(db, table))
26
+ db.exec('DELETE FROM ' + table);
27
+ });
28
+ }
29
+ const validFile = (path) => path === 'service.sqlite' || /^blobs[/][a-f0-9]{64}$/.test(path);
30
+ const hasTable = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
31
+ function identity(db) {
32
+ const row = db.prepare('SELECT service_id FROM identity WHERE singleton=1').get();
33
+ if (!row || typeof row.service_id !== 'string')
34
+ throw new Error('Missing service identity');
35
+ return row.service_id;
36
+ }
37
+ function schemaSignature(db) {
38
+ return JSON.stringify(db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").all());
39
+ }
40
+ function historicalSchema(version) {
41
+ const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
42
+ try {
43
+ for (const migration of MIGRATIONS.filter(item => item.version <= version))
44
+ db.exec(migration.sql);
45
+ return schemaSignature(db);
46
+ }
47
+ finally {
48
+ db.close();
49
+ }
50
+ }
51
+ function checkDatabase(db, manifest, schema, upgraded = false) {
52
+ if (!Number.isSafeInteger(manifest.schemaVersion) || manifest.schemaVersion < 11 || manifest.schemaVersion > SCHEMA_VERSION)
53
+ throw new Error('Backup schema unsupported; use matching service version');
54
+ const version = Number(db.prepare('PRAGMA user_version').get().user_version);
55
+ if (version !== (upgraded ? SCHEMA_VERSION : manifest.schemaVersion))
56
+ throw new Error('Backup database schema version does not match manifest');
57
+ if (!upgraded && schema !== undefined && schemaSignature(db) !== (version === SCHEMA_VERSION ? schema : historicalSchema(version)))
58
+ throw new Error('Backup schema differs from trusted local schema');
59
+ if (db.prepare('PRAGMA integrity_check').get().integrity_check !== 'ok' || db.prepare('PRAGMA foreign_key_check').get())
60
+ throw new Error('Backup database failed integrity checks');
61
+ if (identity(db) !== manifest.serviceId)
62
+ throw new Error('Backup identity mismatch');
63
+ const expected = new Map(manifest.files.filter(f => f.path !== 'service.sqlite').map(f => [f.path.slice(6), f]));
64
+ const rows = db.prepare('SELECT sha256,bytes FROM blobs').all();
65
+ if (rows.length !== expected.size || rows.some(row => expected.get(String(row.sha256))?.bytes !== Number(row.bytes)))
66
+ throw new Error('Manifest does not cover database blobs exactly');
67
+ for (const name of ['sessions', 'pairing_codes', 'settings', 'runs', 'backups', 'occupancy', 'interactions'])
68
+ if (!hasTable(db, name))
69
+ throw new Error('Incomplete backup schema');
70
+ }
71
+ async function withTemporaryDatabase(bytes, work) {
72
+ const temporary = await mkdtemp(join(await realpath(tmpdir()), 'workgraph-verify-'));
73
+ try {
74
+ const path = join(temporary, 'service.sqlite');
75
+ await writeNew(path, bytes, 0o600);
76
+ const db = new DatabaseSync(path, { allowExtension: false });
77
+ try {
78
+ db.exec('PRAGMA trusted_schema=OFF');
79
+ return await work(db);
80
+ }
81
+ finally {
82
+ db.close();
83
+ }
84
+ }
85
+ finally {
86
+ await rm(temporary, { recursive: true, force: true });
87
+ }
88
+ }
89
+ async function verify(path, expectedHash, schema) {
90
+ const source = await safePath(path);
91
+ const files = new Map();
92
+ let manifestBytes;
93
+ let archive;
94
+ if ((await lstat(source)).isDirectory())
95
+ manifestBytes = await readSafe(join(source, 'manifest.json'), 8 * 1024 * 1024);
96
+ else {
97
+ archive = JSON.parse((await readSafe(source, MAX_ARCHIVE)).toString());
98
+ if (!archive || archive.format !== 'openworkgraph-backup-1' || typeof archive.manifest !== 'string' || !archive.files || typeof archive.files !== 'object' || Array.isArray(archive.files))
99
+ throw new Error('Invalid backup download');
100
+ manifestBytes = Buffer.from(archive.manifest, 'base64');
101
+ }
102
+ const hash = digest(manifestBytes);
103
+ if (expectedHash !== undefined && hash !== expectedHash)
104
+ throw new Error('Backup manifest checksum mismatch');
105
+ const manifest = JSON.parse(manifestBytes.toString());
106
+ if (manifest.format !== 1 || !Array.isArray(manifest.files) || typeof manifest.serviceId !== 'string' || typeof manifest.createdAt !== 'string' || !Number.isInteger(manifest.schemaVersion))
107
+ throw new Error('Invalid backup manifest');
108
+ identifier(manifest.id);
109
+ let total = manifestBytes.length;
110
+ for (const entry of manifest.files) {
111
+ if (!entry || typeof entry.path !== 'string' || !validFile(entry.path) || files.has(entry.path) || !/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0)
112
+ throw new Error('Unsafe backup manifest entry');
113
+ total += entry.bytes;
114
+ if (total > MAX_ARCHIVE)
115
+ throw new Error('Backup exceeds verification limit');
116
+ const encoded = archive?.files[entry.path];
117
+ if (archive && typeof encoded !== 'string')
118
+ throw new Error('Missing backup file');
119
+ const bytes = archive ? Buffer.from(encoded, 'base64') : await readSafe(join(source, entry.path), entry.bytes);
120
+ if (bytes.length !== entry.bytes || digest(bytes) !== entry.sha256 || (entry.path.startsWith('blobs/') && entry.sha256 !== entry.path.slice(6)))
121
+ throw new Error('Backup content checksum mismatch');
122
+ files.set(entry.path, bytes);
123
+ }
124
+ if (!files.has('service.sqlite') || (archive && Object.keys(archive.files).length !== files.size))
125
+ throw new Error('Incomplete or extra backup content');
126
+ await withTemporaryDatabase(files.get('service.sqlite'), async (db) => {
127
+ checkDatabase(db, manifest, schema);
128
+ if (manifest.schemaVersion < SCHEMA_VERSION) {
129
+ migrate(db);
130
+ checkDatabase(db, manifest, undefined, true);
131
+ }
132
+ });
133
+ return { manifest, sha256: hash, bytes: total, files };
134
+ }
135
+ /** Only create/list/download may be wired to HTTP. Restore is local CLI only. */
136
+ export class Backups {
137
+ db;
138
+ directories;
139
+ options;
140
+ constructor(db, directories, options = {}) {
141
+ this.db = db;
142
+ this.directories = directories;
143
+ this.options = options;
144
+ }
145
+ /** Startup only, before HTTP/runtime dispatch, under exclusive service ownership.
146
+ * Reconcile committed directories only; incomplete staging is retained for diagnosis. */
147
+ async recoverCreating(lock) {
148
+ const requireOwnership = () => {
149
+ const file = this.db.prepare('PRAGMA database_list').all().find(row => row.name === 'main')?.file;
150
+ if (!lock.held || lock.root !== this.directories.root || file !== join(lock.root, 'service.sqlite') || pendingBackups.get(this.db)?.size)
151
+ throw new Error('Backup recovery requires matching exclusive startup ownership and no pending backups');
152
+ };
153
+ requireOwnership();
154
+ const schema = schemaSignature(this.db), serviceId = identity(this.db);
155
+ for (const row of this.db.prepare("SELECT id,path,created_at FROM backups WHERE state='creating'").all()) {
156
+ let recovered;
157
+ try {
158
+ const id = identifier(String(row.id)), path = join(this.directories.backups, id);
159
+ if (row.path !== path)
160
+ throw new Error('Backup catalogue path mismatch');
161
+ const verified = await verify(path, undefined, schema);
162
+ if (verified.manifest.id !== id || verified.manifest.serviceId !== serviceId || verified.manifest.createdAt !== row.created_at)
163
+ throw new Error('Backup catalogue identity mismatch');
164
+ recovered = verified;
165
+ }
166
+ catch { /* Missing/incomplete/invalid files cannot become downloadable. */ }
167
+ requireOwnership();
168
+ const state = recovered ? 'ready' : 'failed';
169
+ atomic(this.db, () => {
170
+ if (this.db.prepare('SELECT state FROM backups WHERE id=?').get(row.id)?.state !== 'creating')
171
+ throw new Error('Backup changed during startup recovery');
172
+ this.change(String(row.id), state, () => {
173
+ this.db.prepare('UPDATE backups SET state=?,bytes=?,sha256=? WHERE id=?').run(state, recovered?.bytes ?? null, recovered?.sha256 ?? null, row.id);
174
+ });
175
+ });
176
+ }
177
+ }
178
+ change(id, state, write) {
179
+ atomic(this.db, () => {
180
+ write();
181
+ const row = this.db.prepare('SELECT created_at,bytes,sha256 FROM backups WHERE id=?').get(id);
182
+ new Repositories(this.db).appendEvent({ eventId: randomUUID(), type: 'backup.changed', projectId: null, graphId: null, entityId: id, revision: state === 'creating' ? 0 : 1, occurredAt: new Date().toISOString(), payload: { backupId: id, state, createdAt: String(row.created_at), bytes: row.bytes === null ? null : Number(row.bytes), sha256: row.sha256 === null ? null : String(row.sha256) } });
183
+ });
184
+ }
185
+ requireMaintenance() {
186
+ const lock = this.options.maintenance;
187
+ if (!lock?.held || lock.root !== this.directories.root)
188
+ throw new Error('Restore requires exclusive local maintenance; stop service first');
189
+ return lock;
190
+ }
191
+ async list() {
192
+ // Display metadata only: download continues to resolve a validated ID under
193
+ // directories.backups, never a client-provided location.
194
+ return this.db.prepare('SELECT id,state,created_at,bytes,sha256,path FROM backups ORDER BY created_at DESC,id').all().map(row => ({ id: String(row.id), state: row.state, createdAt: String(row.created_at), bytes: row.bytes === null ? null : Number(row.bytes), sha256: row.sha256 === null ? null : String(row.sha256), ...(typeof row.path === 'string' && row.path ? { location: row.path } : {}) }));
195
+ }
196
+ async create(idempotencyKey = randomUUID()) {
197
+ if (!this.options.maintenance && this.db.prepare("SELECT value FROM settings WHERE key='acceptingRuns'").get()?.value !== 'true')
198
+ throw new ServiceError('MAINTENANCE', 'Service is draining; new backups are unavailable');
199
+ let pending = pendingBackups.get(this.db);
200
+ if (!pending) {
201
+ pending = new Set();
202
+ pendingBackups.set(this.db, pending);
203
+ }
204
+ const operation = this.createSnapshot(idempotencyKey);
205
+ pending.add(operation);
206
+ try {
207
+ return await operation;
208
+ }
209
+ finally {
210
+ pending.delete(operation);
211
+ }
212
+ }
213
+ async createSnapshot(idempotencyKey) {
214
+ identifier(idempotencyKey);
215
+ const id = digest(identity(this.db) + ':' + idempotencyKey);
216
+ const old = (await this.list()).find(item => item.id === id);
217
+ if (old)
218
+ return old;
219
+ const work = async () => {
220
+ // No asynchronous gap between the authoritative retry check and insert.
221
+ const exists = this.db.prepare('SELECT 1 FROM backups WHERE id=?').get(id);
222
+ if (exists)
223
+ return (await this.list()).find(item => item.id === id);
224
+ const createdAt = new Date().toISOString();
225
+ const final = join(this.directories.backups, id);
226
+ const temporary = join(this.directories.backups, '.' + id + '.' + randomUUID());
227
+ this.change(id, 'creating', () => { this.db.prepare("INSERT INTO backups(id,state,created_at,path) VALUES(?,'creating',?,?)").run(id, createdAt, final); });
228
+ try {
229
+ await privateDir(temporary);
230
+ await privateDir(join(temporary, 'blobs'));
231
+ const snapshot = join(temporary, 'service.sqlite');
232
+ // One SQLite consistent snapshot including WAL, under host GC exclusion.
233
+ await createDatabaseSnapshot(this.directories.database, snapshot);
234
+ const copied = new DatabaseSync(snapshot, { readOnly: true, allowExtension: false });
235
+ let total, manifestHash;
236
+ try {
237
+ const entries = [];
238
+ for (const row of copied.prepare('SELECT sha256,bytes FROM blobs ORDER BY sha256').all()) {
239
+ const sha = String(row.sha256);
240
+ if (!/^[a-f0-9]{64}$/.test(sha))
241
+ throw new Error('Invalid blob hash');
242
+ const stored = hasTable(copied, 'resource_blob_files') ? copied.prepare('SELECT path FROM resource_blob_files WHERE sha256=?').get(sha) : undefined;
243
+ if (!stored)
244
+ throw new Error('Blob has no immutable file mapping');
245
+ // Normalize Windows 8.3 aliases on both sides after rejecting links.
246
+ const sourcePath = await realpath(await safePath(String(stored.path)));
247
+ const objects = await realpath(await safePath(join(this.directories.blobs, 'objects')));
248
+ if (!sourcePath.startsWith(objects + sep) || !/^[a-f0-9-]{36}$/.test(sourcePath.slice(objects.length + 1)))
249
+ throw new Error('Blob mapping escapes managed storage');
250
+ const bytes = await readSafe(sourcePath, Number(row.bytes));
251
+ if (bytes.length !== Number(row.bytes) || digest(bytes) !== sha)
252
+ throw new Error('Source blob checksum mismatch');
253
+ await writeNew(join(temporary, 'blobs', sha), bytes);
254
+ entries.push({ path: 'blobs/' + sha, sha256: sha, bytes: bytes.length });
255
+ }
256
+ const bytes = await readSafe(snapshot), file = await open(snapshot, 'r+');
257
+ try {
258
+ await file.sync();
259
+ await file.chmod(0o400);
260
+ }
261
+ finally {
262
+ await file.close();
263
+ }
264
+ entries.unshift({ path: 'service.sqlite', bytes: bytes.length, sha256: digest(bytes) });
265
+ const manifest = { format: 1, id, serviceId: identity(copied), schemaVersion: Number(copied.prepare('PRAGMA user_version').get().user_version), createdAt, files: entries };
266
+ checkDatabase(copied, manifest);
267
+ const serialized = JSON.stringify(manifest);
268
+ const encodedBytes = 4 * Math.ceil(Buffer.byteLength(serialized) / 3) + entries.reduce((total, entry) => total + 4 * Math.ceil(entry.bytes / 3) + Buffer.byteLength(JSON.stringify(entry.path)) + 4, 0) + 128;
269
+ if (encodedBytes > MAX_ARCHIVE)
270
+ throw new Error('Backup exceeds bounded portable archive limit');
271
+ await writeNew(join(temporary, 'manifest.json'), serialized);
272
+ await syncDir(join(temporary, 'blobs'));
273
+ await syncDir(temporary);
274
+ total = entries.reduce((sum, file) => sum + file.bytes, Buffer.byteLength(serialized));
275
+ manifestHash = digest(serialized);
276
+ }
277
+ finally {
278
+ copied.close();
279
+ }
280
+ // Windows refuses renaming a directory containing an open SQLite file.
281
+ await rename(temporary, final);
282
+ await syncDir(this.directories.backups);
283
+ this.change(id, 'ready', () => { this.db.prepare("UPDATE backups SET state='ready',bytes=?,sha256=? WHERE id=?").run(total, manifestHash, id); });
284
+ return (await this.list()).find(item => item.id === id);
285
+ }
286
+ catch (error) {
287
+ this.change(id, 'failed', () => { this.db.prepare("UPDATE backups SET state='failed' WHERE id=?").run(id); });
288
+ // No automatic cleanup; incomplete dirs are never downloadable.
289
+ throw error;
290
+ }
291
+ };
292
+ if (this.options.maintenance) {
293
+ this.requireMaintenance();
294
+ return work();
295
+ }
296
+ if (!this.options.withBlobLease)
297
+ throw new Error('Online backup requires host GC-exclusion lease');
298
+ return this.options.withBlobLease(work);
299
+ }
300
+ async readDownload(id) {
301
+ identifier(id);
302
+ const record = (await this.list()).find(item => item.id === id);
303
+ if (!record || record.state !== 'ready' || !record.sha256)
304
+ throw new Error('Backup is not ready');
305
+ const path = join(this.directories.backups, id), verified = await verify(path, record.sha256, schemaSignature(this.db));
306
+ const manifest = await readSafe(join(path, 'manifest.json'));
307
+ const body = Buffer.from(JSON.stringify({ format: 'openworkgraph-backup-1', manifest: manifest.toString('base64'), files: Object.fromEntries([...verified.files].map(([name, bytes]) => [name, bytes.toString('base64')])) }));
308
+ if (body.length > MAX_ARCHIVE)
309
+ throw new Error('Download exceeds bounded archive limit');
310
+ return { filename: id + '.owg-backup', contentType: 'application/vnd.openworkgraph.backup+json', body };
311
+ }
312
+ async previewRestore(path) {
313
+ const verified = await verify(path, undefined, schemaSignature(this.db));
314
+ if (verified.manifest.serviceId !== identity(this.db))
315
+ throw new Error('Restore cannot replace another service identity');
316
+ return { backupId: verified.manifest.id, integrity: 'verified', requiresMaintenance: true, invalidatesAllSessions: true, pausesQueuedRuns: true, sha256: verified.sha256, serviceId: verified.manifest.serviceId };
317
+ }
318
+ async restore(path, expectedSha256) {
319
+ this.requireMaintenance();
320
+ if (!/^[a-f0-9]{64}$/.test(expectedSha256))
321
+ throw new Error('Restore requires preview manifest checksum');
322
+ // Complete integrity/schema/blob validation BEFORE live storage writes.
323
+ // Retain verified bytes; never reopen untrusted input after validation.
324
+ const verified = await verify(path, expectedSha256, schemaSignature(this.db));
325
+ if (verified.manifest.serviceId !== identity(this.db))
326
+ throw new Error('Restore cannot replace another service identity');
327
+ await withTemporaryDatabase(verified.files.get('service.sqlite'), async (restored) => {
328
+ if (verified.manifest.schemaVersion < SCHEMA_VERSION)
329
+ migrate(restored);
330
+ const mappings = [];
331
+ await privateDir(join(this.directories.blobs, 'objects'));
332
+ for (const [name, bytes] of verified.files) {
333
+ if (name === 'service.sqlite')
334
+ continue;
335
+ const destination = join(this.directories.blobs, 'objects', randomUUID());
336
+ await writeNew(destination, bytes);
337
+ mappings.push([name.slice(6), destination]);
338
+ }
339
+ await syncDir(join(this.directories.blobs, 'objects'));
340
+ transaction(restored, () => {
341
+ // Every imported token is revoked on EVERY restore, even old tombstones.
342
+ restored.prepare('UPDATE sessions SET revoked_at=?').run(Date.now());
343
+ if (hasTable(restored, 'image_provider_revoked_credentials')) {
344
+ for (const row of restored.prepare('SELECT id,credential_counter FROM image_providers').all()) {
345
+ for (let revision = 1; revision <= Number(row.credential_counter); revision++)
346
+ restored.prepare('INSERT OR IGNORE INTO image_provider_revoked_credentials(provider_id,revision) VALUES(?,?)').run(String(row.id), revision);
347
+ }
348
+ restored.exec('UPDATE image_providers SET credential_revision=NULL; UPDATE image_provider_models SET verified_at=NULL;');
349
+ }
350
+ restored.exec("DELETE FROM pairing_codes; UPDATE interactions SET status='expired',version=version+1 WHERE status='pending'; DELETE FROM settings WHERE key IN ('listener','operationsInstance','backupRestorePending'); INSERT INTO settings(key,value) VALUES('backupRestorePending','true'); UPDATE settings SET value='true' WHERE key='acceptingRuns';");
351
+ restored.exec("UPDATE runs SET status=CASE WHEN status IN ('accepted','queued') THEN 'paused_restore' WHEN status IN ('agent_completed','finalizing') THEN 'finalizing' ELSE 'reconciling' END WHERE status NOT IN ('succeeded','failed','cancelled','interrupted','paused_restore');");
352
+ // Persisted backend completion is not backend uncertainty. Continue the
353
+ // local idempotent publication path from retained result/manifest; never
354
+ // ask a fresh unattached backend to reconcile or replay completed work.
355
+ // Unknown active work keeps capacity until reconciled. The scheduler
356
+ // reacquires one shared cross-runtime project lock for active executions.
357
+ // Never make an uncertain backend disappear merely by clearing occupancy.
358
+ restored.exec("DELETE FROM occupancy WHERE run_id IN (SELECT id FROM runs WHERE status NOT IN ('reconciling','finalizing'));");
359
+ const slots = new Set(restored.prepare('SELECT slot FROM occupancy').all().map(row => Number(row.slot)));
360
+ for (const row of restored.prepare("SELECT id,kind,project_id FROM runs WHERE status IN ('reconciling','finalizing') AND id NOT IN (SELECT run_id FROM occupancy) ORDER BY sequence").all()) {
361
+ let slot = 0;
362
+ while (slots.has(slot))
363
+ slot++;
364
+ slots.add(slot);
365
+ restored.prepare('INSERT INTO occupancy(run_id,slot,project_id) VALUES(?,?,NULL)').run(String(row.id), slot);
366
+ }
367
+ if (hasTable(restored, 'run_runtime'))
368
+ restored.prepare('UPDATE run_runtime SET epoch=?,revision=revision+1').run(randomUUID());
369
+ for (const table of ['resource_upload_chunks', 'resource_uploads', 'resource_file_deletions', 'resource_read_leases', 'resource_maintenance_leases'])
370
+ if (hasTable(restored, table))
371
+ restored.exec('DELETE FROM ' + table);
372
+ if (hasTable(restored, 'resource_blob_files')) {
373
+ restored.exec('DELETE FROM resource_blob_files');
374
+ for (const [sha, file] of mappings)
375
+ restored.prepare('INSERT INTO resource_blob_files(sha256,path) VALUES(?,?)').run(sha, file);
376
+ }
377
+ // Embedded catalogue rows may describe absent/incomplete directories.
378
+ for (const row of restored.prepare("SELECT id FROM backups WHERE state!='failed'").all()) {
379
+ const id = String(row.id);
380
+ restored.prepare("UPDATE backups SET state='failed' WHERE id=?").run(id);
381
+ new Repositories(restored).appendEvent({ eventId: randomUUID(), type: 'backup.changed', projectId: null, graphId: null, entityId: id, revision: 2, occurredAt: new Date().toISOString(), payload: { backupId: id, state: 'failed', reason: 'restored_catalogue_requires_local_revalidation' } });
382
+ }
383
+ });
384
+ checkDatabase(restored, verified.manifest, undefined, true);
385
+ this.requireMaintenance();
386
+ // SQLite replaces destination transactionally, not main-file rename over WAL.
387
+ await backup(restored, this.directories.database);
388
+ });
389
+ }
390
+ }
391
+ //# sourceMappingURL=backups.js.map
@@ -0,0 +1,7 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ /** Capacity changes affect future claims only, never cancel occupied work. */
3
+ export declare function localCapacity(db: DatabaseSync, input?: string): {
4
+ capacity: number;
5
+ occupied: number;
6
+ available: number;
7
+ };
@@ -0,0 +1,35 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { atomic } from '../persistence/database.js';
3
+ import { Repositories } from '../persistence/repositories.js';
4
+ /** Capacity changes affect future claims only, never cancel occupied work. */
5
+ export function localCapacity(db, input) {
6
+ return atomic(db, () => {
7
+ let changed = false;
8
+ if (input !== undefined) {
9
+ const value = Number(input);
10
+ if (!/^[1-9][0-9]*$/.test(input) || !Number.isSafeInteger(value))
11
+ throw new Error('Capacity must be a positive safe integer');
12
+ db.prepare("INSERT INTO settings(key,value) VALUES('capacity',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(value));
13
+ changed = true;
14
+ }
15
+ const row = db.prepare("SELECT value FROM settings WHERE key='capacity'").get();
16
+ const capacity = Number(row?.value);
17
+ if (!Number.isSafeInteger(capacity) || capacity < 1)
18
+ throw new Error('Invalid stored capacity');
19
+ const occupied = Number(db.prepare('SELECT count(*) AS n FROM occupancy').get().n);
20
+ const result = { capacity, occupied, available: Math.max(0, capacity - occupied) };
21
+ if (changed)
22
+ new Repositories(db).appendEvent({
23
+ eventId: randomUUID(),
24
+ type: 'capacity.changed',
25
+ projectId: null,
26
+ graphId: null,
27
+ entityId: 'capacity',
28
+ revision: 1,
29
+ occurredAt: new Date().toISOString(),
30
+ payload: result,
31
+ });
32
+ return result;
33
+ });
34
+ }
35
+ //# sourceMappingURL=capacity.js.map
@@ -0,0 +1,7 @@
1
+ export declare function rememberServiceDirectory(dataDir: string, home?: string): Promise<void>;
2
+ /** A fixed loopback port enforces one CLI service per machine, even for different
3
+ * data directories/users. No requests are served on this ownership-only socket. */
4
+ export declare function acquireServiceSingleton(): Promise<{
5
+ release: () => Promise<void>;
6
+ }>;
7
+ export declare function selectedServiceDirectory(home?: string): Promise<string>;
@@ -0,0 +1,58 @@
1
+ import { join, isAbsolute } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { createServer } from 'node:net';
5
+ import { managementPort } from './lifecycle.js';
6
+ import { atomicJson, privateDir, readSafe, safePath } from './files.js';
7
+ import { ServiceError } from '../errors.js';
8
+ /** Remember the one service's directory, never an IPC credential.
9
+ * A separate home isolates test processes from the user's service and lock. */
10
+ function contextHome() { return process.env.OPENWORKGRAPH_CLI_HOME ?? join(homedir(), '.openworkgraph'); }
11
+ export async function rememberServiceDirectory(dataDir, home = contextHome()) {
12
+ const directory = await realpath(await safePath(dataDir));
13
+ await atomicJson(join(await privateDir(home), 'cli-context.json'), { version: 1, dataDir: directory });
14
+ }
15
+ /** A fixed loopback port enforces one CLI service per machine, even for different
16
+ * data directories/users. No requests are served on this ownership-only socket. */
17
+ export async function acquireServiceSingleton() {
18
+ const ports = process.env.OPENWORKGRAPH_CLI_HOME
19
+ ? [managementPort(join(await privateDir(contextHome()), 'singleton'))]
20
+ : [4316, 14316, 24316, 34316, 44316, 54316];
21
+ for (const port of ports) {
22
+ const server = createServer(socket => socket.destroy());
23
+ try {
24
+ await new Promise((done, reject) => { server.once('error', reject); server.listen({ host: '127.0.0.1', port, exclusive: true }, done); });
25
+ }
26
+ catch (error) {
27
+ // Windows can reserve a port without a listener. Every process tries the
28
+ // same ordered list; an occupied usable port still means another owner.
29
+ if (error.code === 'EACCES' && port !== ports.at(-1))
30
+ continue;
31
+ const message = error.code === 'EACCES'
32
+ ? 'No available local control port; check OS port reservations.'
33
+ : 'A local service is already running or starting, or the control port is occupied. Run status, then stop before starting again.';
34
+ throw new ServiceError('INVALID_REQUEST', message, { cause: error });
35
+ }
36
+ // Keep ownership for the process lifetime without preventing normal shutdown.
37
+ server.unref();
38
+ return { release: () => new Promise((done, reject) => server.close(error => error ? reject(error) : done())) };
39
+ }
40
+ throw new ServiceError('INVALID_REQUEST', 'No available local control port; check OS port reservations.');
41
+ }
42
+ export async function selectedServiceDirectory(home = contextHome()) {
43
+ let content;
44
+ try {
45
+ content = await readSafe(join(home, 'cli-context.json'), 8192);
46
+ }
47
+ catch (error) {
48
+ if (error.code === 'ENOENT')
49
+ throw new ServiceError('SERVICE_NOT_RUNNING', 'No service has been started. Run start or serve first; only startup commands accept a data directory.');
50
+ throw error;
51
+ }
52
+ const record = JSON.parse(content.toString());
53
+ if (record.version !== 1 || typeof record.dataDir !== 'string' || !isAbsolute(record.dataDir))
54
+ throw new ServiceError('INVALID_REQUEST', 'The saved service directory is invalid. Start the service again with start or serve.');
55
+ // Never silently fall back to another directory when the record is stale.
56
+ return realpath(await safePath(record.dataDir));
57
+ }
58
+ //# sourceMappingURL=cli-context.js.map
@@ -0,0 +1,36 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export declare function activeRunCount(db: DatabaseSync): number;
3
+ /** Public host wrapper: the lock is acquired BEFORE opening SQLite or listeners. */
4
+ export declare function serveManaged(dataDir: string | undefined, host: string, port: number, onStopped?: () => Promise<void>): Promise<{
5
+ service: {
6
+ server: import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
7
+ db: DatabaseSync;
8
+ directories: import("../directories.js").DataDirectories;
9
+ info: import("../vendor/protocol/index.js").ServiceInfo;
10
+ auth: import("../auth.js").Auth;
11
+ projects: import("../projects.js").Projects;
12
+ api: import("../api.js").BusinessApi;
13
+ events: import("../events.js").Events;
14
+ runtime: import("../runtime.js").WorkflowRuntime;
15
+ instanceId: `${string}-${string}-${string}-${string}-${string}`;
16
+ close: () => Promise<void>;
17
+ abortStartup: () => Promise<void>;
18
+ };
19
+ lock: {
20
+ root: string;
21
+ record: import("./lifecycle.js").InstanceRecord;
22
+ readonly held: boolean;
23
+ publish(serviceId: string, next: import("./lifecycle.js").ManagementHandler, instanceId?: string): Promise<void>;
24
+ release(): Promise<void>;
25
+ };
26
+ status: () => {
27
+ serviceId: string;
28
+ instanceId: string;
29
+ localEndpoint: string;
30
+ endpoints: string[];
31
+ state: "draining" | "failed" | "interrupting" | "running";
32
+ activeRuns: number;
33
+ listenHost: string;
34
+ };
35
+ stop: (interrupt: boolean) => Promise<void>;
36
+ }>;