@hraness/sponge 0.2.6 → 0.3.1

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 (227) hide show
  1. package/README.md +21 -9
  2. package/bin/sponge.js +8 -7
  3. package/dist/contracts/index.js +24 -6
  4. package/dist/contracts/validate.js +106 -29
  5. package/dist/migrations/008.d.ts +3 -0
  6. package/dist/migrations/008.js +5 -0
  7. package/dist/migrations/009.d.ts +3 -0
  8. package/dist/migrations/009.js +8 -0
  9. package/dist/migrations/010.d.ts +3 -0
  10. package/dist/migrations/010.js +11 -0
  11. package/dist/migrations/index.d.ts +15 -0
  12. package/dist/migrations/index.js +39 -0
  13. package/dist/src/acquisition/capture.js +87 -28
  14. package/dist/src/acquisition/cleanup.d.ts +0 -4
  15. package/dist/src/acquisition/cleanup.js +3 -5
  16. package/dist/src/acquisition/errors.js +17 -2
  17. package/dist/src/acquisition/files.d.ts +0 -1
  18. package/dist/src/acquisition/files.js +14 -11
  19. package/dist/src/acquisition/index.d.ts +2 -3
  20. package/dist/src/acquisition/index.js +1 -2
  21. package/dist/src/acquisition/lock.js +1 -1
  22. package/dist/src/acquisition/normalize.js +57 -18
  23. package/dist/src/acquisition/pins.js +22 -4
  24. package/dist/src/acquisition/process.js +67 -29
  25. package/dist/src/acquisition/runtime.d.ts +5 -7
  26. package/dist/src/acquisition/runtime.js +129 -68
  27. package/dist/src/acquisition/verify-helper.js +10 -3
  28. package/dist/src/annotations/quote.js +40 -12
  29. package/dist/src/annotations/store.d.ts +0 -1
  30. package/dist/src/annotations/store.js +66 -14
  31. package/dist/src/app/article.d.ts +2 -1
  32. package/dist/src/app/article.js +66 -70
  33. package/dist/src/app/bundle-files.d.ts +0 -4
  34. package/dist/src/app/bundle-files.js +11 -14
  35. package/dist/src/app/markdown.js +18 -6
  36. package/dist/src/app/source.d.ts +0 -4
  37. package/dist/src/app/source.js +18 -14
  38. package/dist/src/cli/arguments.d.ts +2 -8
  39. package/dist/src/cli/arguments.js +67 -80
  40. package/dist/src/cli/commands.d.ts +42 -0
  41. package/dist/src/cli/commands.js +795 -0
  42. package/dist/src/cli/config.js +12 -9
  43. package/dist/src/cli/digest.d.ts +1 -1
  44. package/dist/src/cli/digest.js +38 -16
  45. package/dist/src/cli/import.js +25 -4
  46. package/dist/src/cli/indexing.d.ts +2 -0
  47. package/dist/src/cli/indexing.js +26 -9
  48. package/dist/src/cli/main.js +47 -386
  49. package/dist/src/cli/portability.d.ts +0 -2
  50. package/dist/src/cli/portability.js +13 -8
  51. package/dist/src/cli/queue.js +12 -8
  52. package/dist/src/cli/runtimes.d.ts +1 -3
  53. package/dist/src/cli/runtimes.js +13 -6
  54. package/dist/src/cli/terminal.js +1 -1
  55. package/dist/src/cli/worker.d.ts +7 -0
  56. package/dist/src/cli/worker.js +54 -15
  57. package/dist/src/derivations/kinds.d.ts +34 -0
  58. package/dist/src/derivations/kinds.js +14 -0
  59. package/dist/src/derivations/types.d.ts +48 -0
  60. package/dist/src/derivations/types.js +1 -0
  61. package/dist/src/digest/derivation.d.ts +25 -0
  62. package/dist/src/digest/derivation.js +124 -0
  63. package/dist/src/digest/editorial.d.ts +9 -46
  64. package/dist/src/digest/editorial.js +57 -49
  65. package/dist/src/digest/endpoint.js +63 -27
  66. package/dist/src/digest/pipeline.d.ts +49 -28
  67. package/dist/src/digest/pipeline.js +369 -102
  68. package/dist/src/digest/schema.d.ts +11 -25
  69. package/dist/src/digest/schema.js +86 -18
  70. package/dist/src/errors.d.ts +46 -0
  71. package/dist/src/errors.js +117 -0
  72. package/dist/src/identity/query-policy.d.ts +0 -5
  73. package/dist/src/identity/query-policy.js +61 -9
  74. package/dist/src/identity/uri.d.ts +6 -5
  75. package/dist/src/identity/uri.js +37 -18
  76. package/dist/src/identity/visibility.d.ts +11 -1
  77. package/dist/src/identity/visibility.js +81 -17
  78. package/dist/src/importers/apply.js +104 -36
  79. package/dist/src/importers/custody.js +27 -9
  80. package/dist/src/importers/inspect.js +125 -38
  81. package/dist/src/importers/jungle-digest.d.ts +3 -6
  82. package/dist/src/importers/jungle-digest.js +196 -69
  83. package/dist/src/importers/jungle-store.d.ts +19 -6
  84. package/dist/src/importers/jungle-store.js +124 -28
  85. package/dist/src/importers/jungle.d.ts +14 -14
  86. package/dist/src/importers/jungle.js +142 -31
  87. package/dist/src/importers/manifest.js +57 -21
  88. package/dist/src/importers/verify-helper.js +18 -4
  89. package/dist/src/indexing/image.js +29 -10
  90. package/dist/src/indexing/index.d.ts +37 -6
  91. package/dist/src/indexing/index.js +360 -124
  92. package/dist/src/indexing/jobs.js +156 -47
  93. package/dist/src/indexing/prune-run.d.ts +2 -0
  94. package/dist/src/indexing/prune-run.js +69 -26
  95. package/dist/src/indexing/prune.d.ts +12 -3
  96. package/dist/src/indexing/prune.js +85 -35
  97. package/dist/src/indexing/runner.d.ts +2 -1
  98. package/dist/src/indexing/runner.js +83 -40
  99. package/dist/src/indexing/upgrade.d.ts +9 -0
  100. package/dist/src/indexing/upgrade.js +79 -0
  101. package/dist/src/inference/asr/bridge.js +6 -2
  102. package/dist/src/inference/asr/protocol.js +109 -19
  103. package/dist/src/inference/asr/runtime.js +58 -21
  104. package/dist/src/inference/asr/windows.js +9 -5
  105. package/dist/src/inference/embedding/index.d.ts +2 -6
  106. package/dist/src/inference/embedding/index.js +89 -29
  107. package/dist/src/ingest/attempt.d.ts +29 -0
  108. package/dist/src/ingest/attempt.js +55 -0
  109. package/dist/src/media/capture-helper.js +7 -2
  110. package/dist/src/media/capture.d.ts +2 -1
  111. package/dist/src/media/capture.js +124 -43
  112. package/dist/src/media/handler.d.ts +4 -2
  113. package/dist/src/media/handler.js +88 -70
  114. package/dist/src/media/transcribe.js +63 -21
  115. package/dist/src/metadata/article.js +49 -17
  116. package/dist/src/models/artifacts.d.ts +10 -8
  117. package/dist/src/models/artifacts.js +237 -106
  118. package/dist/src/models/catalog.d.ts +2 -5
  119. package/dist/src/models/catalog.js +94 -25
  120. package/dist/src/models/download.js +55 -15
  121. package/dist/src/models/runtime.d.ts +0 -2
  122. package/dist/src/models/runtime.js +69 -33
  123. package/dist/src/pdf/capture-helper.js +3 -1
  124. package/dist/src/pdf/capture.d.ts +2 -1
  125. package/dist/src/pdf/capture.js +95 -27
  126. package/dist/src/pdf/errors.js +12 -1
  127. package/dist/src/pdf/handler.d.ts +2 -1
  128. package/dist/src/pdf/handler.js +70 -71
  129. package/dist/src/pdf/index.d.ts +1 -3
  130. package/dist/src/pdf/index.js +0 -2
  131. package/dist/src/pdf/input.js +12 -7
  132. package/dist/src/pdf/normalize.js +80 -21
  133. package/dist/src/pdf/source-identity.js +3 -2
  134. package/dist/src/pdf/source.js +12 -8
  135. package/dist/src/pdf/verify-helper.js +15 -3
  136. package/dist/src/portability/backup.d.ts +2 -9
  137. package/dist/src/portability/backup.js +106 -22
  138. package/dist/src/portability/database.js +51 -17
  139. package/dist/src/portability/export.js +35 -9
  140. package/dist/src/portability/files.d.ts +1 -4
  141. package/dist/src/portability/files.js +127 -67
  142. package/dist/src/portability/gc.js +65 -22
  143. package/dist/src/publish/component.js +1 -1
  144. package/dist/src/publish/feed.d.ts +2 -2
  145. package/dist/src/publish/feed.js +75 -29
  146. package/dist/src/publish/identity.d.ts +5 -1
  147. package/dist/src/publish/identity.js +31 -11
  148. package/dist/src/publish/index.d.ts +2 -3
  149. package/dist/src/publish/index.js +34 -21
  150. package/dist/src/publish/projection.d.ts +2 -9
  151. package/dist/src/publish/projection.js +21 -14
  152. package/dist/src/search/extension.js +3 -1
  153. package/dist/src/search/hybrid.d.ts +3 -8
  154. package/dist/src/search/hybrid.js +45 -11
  155. package/dist/src/search/lexical.js +18 -4
  156. package/dist/src/search/query.js +56 -25
  157. package/dist/src/search/vector.js +37 -10
  158. package/dist/src/service/launch-agent.d.ts +5 -3
  159. package/dist/src/service/launch-agent.js +272 -83
  160. package/dist/src/service/lifetime.js +47 -14
  161. package/dist/src/service/loop.d.ts +3 -3
  162. package/dist/src/service/loop.js +47 -19
  163. package/dist/src/store/annotations.d.ts +32 -0
  164. package/dist/src/store/annotations.js +44 -0
  165. package/dist/src/store/backups.d.ts +18 -0
  166. package/dist/src/store/backups.js +110 -0
  167. package/dist/src/store/blobs.d.ts +24 -0
  168. package/dist/src/store/blobs.js +139 -0
  169. package/dist/src/store/changes.d.ts +5 -12
  170. package/dist/src/store/changes.js +108 -56
  171. package/dist/src/store/context.d.ts +15 -0
  172. package/dist/src/store/context.js +19 -0
  173. package/dist/src/store/derivations.d.ts +42 -0
  174. package/dist/src/store/derivations.js +104 -0
  175. package/dist/src/store/files.d.ts +0 -2
  176. package/dist/src/store/files.js +19 -24
  177. package/dist/src/store/ids.d.ts +30 -0
  178. package/dist/src/store/ids.js +34 -0
  179. package/dist/src/store/imports.d.ts +26 -0
  180. package/dist/src/store/imports.js +57 -0
  181. package/dist/src/store/index.d.ts +3 -186
  182. package/dist/src/store/index.js +2 -809
  183. package/dist/src/store/lease.d.ts +20 -0
  184. package/dist/src/store/lease.js +62 -0
  185. package/dist/src/store/library.d.ts +75 -0
  186. package/dist/src/store/library.js +174 -0
  187. package/dist/src/store/open.d.ts +7 -0
  188. package/dist/src/store/open.js +183 -0
  189. package/dist/src/store/queue.d.ts +65 -0
  190. package/dist/src/store/queue.js +327 -0
  191. package/dist/src/store/revisions.d.ts +12 -0
  192. package/dist/src/store/revisions.js +189 -0
  193. package/dist/src/store/search-index.d.ts +63 -0
  194. package/dist/src/store/search-index.js +190 -0
  195. package/dist/src/store/sync-ready.d.ts +6 -0
  196. package/dist/src/store/sync-ready.js +121 -0
  197. package/dist/src/store/types.d.ts +1 -5
  198. package/dist/src/store/types.js +1 -8
  199. package/dist/src/util/canonical.d.ts +2 -0
  200. package/dist/src/util/canonical.js +17 -0
  201. package/dist/src/util/exact.d.ts +2 -0
  202. package/dist/src/util/exact.js +9 -0
  203. package/dist/src/util/fsync.d.ts +4 -0
  204. package/dist/src/util/fsync.js +22 -0
  205. package/dist/src/util/hash.d.ts +4 -0
  206. package/dist/src/util/hash.js +8 -0
  207. package/dist/src/worker/basic.d.ts +0 -1
  208. package/dist/src/worker/basic.js +23 -7
  209. package/dist/src/worker/custody.d.ts +4 -1
  210. package/dist/src/worker/custody.js +111 -38
  211. package/dist/src/worker/engine.d.ts +2 -1
  212. package/dist/src/worker/engine.js +42 -20
  213. package/dist/src/worker/identity.js +13 -5
  214. package/dist/src/worker/journal.d.ts +1 -1
  215. package/dist/src/worker/journal.js +54 -24
  216. package/dist/src/worker/lock.d.ts +18 -0
  217. package/dist/src/worker/lock.js +196 -0
  218. package/dist/src/worker/runtime-gc.d.ts +4 -3
  219. package/dist/src/worker/runtime-gc.js +68 -41
  220. package/dist/src/worker/supervisor-child.js +19 -6
  221. package/dist/src/worker/supervisor.js +41 -8
  222. package/native/runtime-manifest.json +4 -4
  223. package/package.json +10 -2
  224. package/dist/src/digest/store.d.ts +0 -57
  225. package/dist/src/digest/store.js +0 -79
  226. package/dist/src/store/foundation.d.ts +0 -7
  227. package/dist/src/store/foundation.js +0 -7
@@ -4,86 +4,201 @@ import { homedir } from 'node:os';
4
4
  import { join, dirname, resolve } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { realpathSync, readdirSync, lstatSync, readFileSync, existsSync, unlinkSync } from 'node:fs';
7
- import { StoreError } from '../store/types.js';
8
- import { digest, safeDirectory, regularFile, syncDirectory } from '../store/files.js';
7
+ import { safeDirectory, regularFile } from '../store/files.js';
8
+ import { sha256 } from '../util/hash.js';
9
+ import { syncDirectory } from '../util/fsync.js';
9
10
  import { describe, exact, readExact, openRead, writePrivateExclusive } from '../portability/files.js';
10
11
  import { fstatSync, closeSync } from 'node:fs';
11
12
  import { identityAlive } from '../worker/identity.js';
12
13
  import { recoverServiceLifetime } from './lifetime.js';
14
+ import { SpongeError } from '../errors.js';
13
15
  const exec = promisify(execFile);
14
- export function serviceEnvironment(version) { return { platform: process.platform, uid: process.getuid?.() ?? -1, home: realpathSync(homedir()), node: realpathSync(process.execPath), cli: realpathSync(process.argv[1]), version, launchctl: async (args) => { try {
15
- const r = await exec('/bin/launchctl', args, { timeout: 30000, maxBuffer: 65536, env: { HOME: homedir(), PATH: '/usr/bin:/bin', LANG: 'en_US.UTF-8' } });
16
- return { code: 0, stdout: r.stdout };
16
+ export function serviceEnvironment(version) {
17
+ return {
18
+ platform: process.platform,
19
+ uid: process.getuid?.() ?? -1,
20
+ home: realpathSync(homedir()),
21
+ node: realpathSync(process.execPath),
22
+ cli: realpathSync(process.argv[1]),
23
+ version,
24
+ launchctl: async (args) => {
25
+ try {
26
+ const r = await exec('/bin/launchctl', args, {
27
+ timeout: 30000,
28
+ maxBuffer: 65536,
29
+ env: { HOME: homedir(), PATH: '/usr/bin:/bin', LANG: 'en_US.UTF-8' },
30
+ });
31
+ return { code: 0, stdout: r.stdout };
32
+ }
33
+ catch (error) {
34
+ const e = error;
35
+ if (typeof e.code === 'number')
36
+ return { code: e.code, stdout: e.stdout ?? '' };
37
+ throw new SpongeError('unavailable');
38
+ }
39
+ },
40
+ alive: identityAlive,
41
+ pause: (ms) => new Promise((r) => setTimeout(r, ms)),
42
+ };
43
+ }
44
+ function paths(store, env) {
45
+ if (env.platform !== 'darwin')
46
+ throw new SpongeError('unavailable');
47
+ if (!Number.isSafeInteger(env.uid) || env.uid <= 0)
48
+ throw new SpongeError('invalid-input');
49
+ const root = join(store.root, 'runtime', 'service'), agents = join(env.home, 'Library', 'LaunchAgents');
50
+ const label = 'computer.sponge.worker.' + sha256(store.root).slice(0, 20);
51
+ return {
52
+ root,
53
+ agents,
54
+ label,
55
+ log: join(root, 'launchd.log'),
56
+ record: join(root, 'installation.json'),
57
+ plist: join(agents, label + '.plist'),
58
+ target: `gui/${env.uid}/${label}`,
59
+ };
60
+ }
61
+ function readJson(path) {
62
+ const fd = openRead(path);
63
+ try {
64
+ const size = fstatSync(fd).size;
65
+ if (size > 4 * 1024 * 1024)
66
+ throw new SpongeError('limit-exceeded');
67
+ return JSON.parse(readExact(fd, size).toString());
17
68
  }
18
- catch (error) {
19
- const e = error;
20
- if (typeof e.code === 'number')
21
- return { code: e.code, stdout: e.stdout ?? '' };
22
- throw new StoreError('unavailable');
23
- } }, alive: identityAlive, pause: ms => new Promise(r => setTimeout(r, ms)) }; }
24
- function paths(store, env) { if (env.platform !== 'darwin')
25
- throw new StoreError('unavailable'); if (!Number.isSafeInteger(env.uid) || env.uid <= 0)
26
- throw new StoreError('invalid-input'); const root = join(store.root, 'runtime', 'service'), agents = join(env.home, 'Library', 'LaunchAgents'); const label = 'computer.sponge.worker.' + digest(store.root).slice(0, 20); return { root, agents, label, record: join(root, 'installation.json'), plist: join(agents, label + '.plist'), target: `gui/${env.uid}/${label}` }; }
27
- function readJson(path) { const fd = openRead(path); try {
28
- const size = fstatSync(fd).size;
29
- if (size > 4 * 1024 * 1024)
30
- throw new StoreError('limit-exceeded');
31
- return JSON.parse(readExact(fd, size).toString());
32
- }
33
- finally {
34
- closeSync(fd);
35
- } }
69
+ finally {
70
+ closeSync(fd);
71
+ }
72
+ }
36
73
  function installation(store, env) {
37
74
  const p = paths(store, env);
38
75
  if (!existsSync(p.record))
39
76
  return null;
40
- const r = exact(readJson(p.record), ['language', 'schemaVersion', 'id', 'label', 'uid', 'version', 'node', 'cli', 'library', 'plist', 'plistHash', 'launcher', 'launcherHash', 'pins']);
41
- if ((r.language !== null && r.language !== 'en') || r.schemaVersion !== 1 || typeof r.id !== 'string' || !/^[a-f0-9-]{36}$/u.test(r.id) || r.label !== p.label || r.uid !== env.uid || r.library !== store.root || r.plist !== p.plist || typeof r.version !== 'string' || !/^\d+\.\d+\.\d+$/u.test(r.version) || typeof r.node !== 'string' || typeof r.cli !== 'string' || typeof r.launcher !== 'string' || typeof r.plistHash !== 'string' || typeof r.launcherHash !== 'string' || !Array.isArray(r.pins) || r.pins.length > 10000)
42
- throw new StoreError('integrity');
43
- if (r.launcher !== join(p.root, 'launchers', r.version + '-' + r.id, 'run.mjs') || !/^\/[\s\S]+/u.test(r.node) || !/^\/[\s\S]+/u.test(r.cli))
44
- throw new StoreError('integrity');
77
+ const r = exact(readJson(p.record), [
78
+ 'language',
79
+ 'schemaVersion',
80
+ 'id',
81
+ 'label',
82
+ 'uid',
83
+ 'version',
84
+ 'node',
85
+ 'cli',
86
+ 'library',
87
+ 'plist',
88
+ 'plistHash',
89
+ 'launcher',
90
+ 'launcherHash',
91
+ 'pins',
92
+ ]);
93
+ if ((r.language !== null && r.language !== 'en') ||
94
+ r.schemaVersion !== 1 ||
95
+ typeof r.id !== 'string' ||
96
+ !/^[a-f0-9-]{36}$/u.test(r.id) ||
97
+ r.label !== p.label ||
98
+ r.uid !== env.uid ||
99
+ r.library !== store.root ||
100
+ r.plist !== p.plist ||
101
+ typeof r.version !== 'string' ||
102
+ !/^\d+\.\d+\.\d+$/u.test(r.version) ||
103
+ typeof r.node !== 'string' ||
104
+ typeof r.cli !== 'string' ||
105
+ typeof r.launcher !== 'string' ||
106
+ typeof r.plistHash !== 'string' ||
107
+ typeof r.launcherHash !== 'string' ||
108
+ !Array.isArray(r.pins) ||
109
+ r.pins.length > 10000)
110
+ throw new SpongeError('integrity');
111
+ if (r.launcher !== join(p.root, 'launchers', r.version + '-' + r.id, 'run.mjs') ||
112
+ !/^\/[\s\S]+/u.test(r.node) ||
113
+ !/^\/[\s\S]+/u.test(r.cli))
114
+ throw new SpongeError('integrity');
45
115
  for (const pin of r.pins) {
46
116
  const f = exact(pin, ['path', 'bytes', 'sha256']);
47
- if (typeof f.path !== 'string' || resolve(f.path) !== f.path || typeof f.bytes !== 'number' || !Number.isSafeInteger(f.bytes) || f.bytes < 0 || typeof f.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(f.sha256))
48
- throw new StoreError('integrity');
117
+ if (typeof f.path !== 'string' ||
118
+ resolve(f.path) !== f.path ||
119
+ typeof f.bytes !== 'number' ||
120
+ !Number.isSafeInteger(f.bytes) ||
121
+ f.bytes < 0 ||
122
+ typeof f.sha256 !== 'string' ||
123
+ !/^[a-f0-9]{64}$/u.test(f.sha256))
124
+ throw new SpongeError('integrity');
49
125
  }
50
126
  return r;
51
127
  }
52
- function verifyOwnedFiles(record) { for (const [path, sha] of [[record.plist, record.plistHash], [record.launcher, record.launcherHash]]) {
53
- if (describe(path, path, 4 * 1024 * 1024).sha256 !== sha)
54
- throw new StoreError('integrity');
55
- } }
56
- function current(record) { try {
57
- return record.pins.every(p => { const r = describe(p.path, p.path, 512 * 1024 ** 2); return r.bytes === p.bytes && r.sha256 === p.sha256; });
58
- }
59
- catch {
60
- return false;
61
- } }
62
- function xml(value) { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;'); }
63
- export function agentPlist(record) { return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(record.label)}</string><key>ProgramArguments</key><array><string>${xml(record.node)}</string><string>${xml(record.launcher)}</string></array><key>RunAtLoad</key><true/><key>StartInterval</key><integer>30</integer><key>ProcessType</key><string>Background</string><key>ExitTimeOut</key><integer>60</integer><key>Umask</key><integer>63</integer><key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/bin:/bin</string><key>LANG</key><string>en_US.UTF-8</string><key>HRANESS_NO_UPDATE</key><string>1</string></dict><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string></dict></plist>\n`; }
128
+ function verifyOwnedFiles(record) {
129
+ for (const [path, sha] of [
130
+ [record.plist, record.plistHash],
131
+ [record.launcher, record.launcherHash],
132
+ ]) {
133
+ if (describe(path, path, 4 * 1024 * 1024).sha256 !== sha)
134
+ throw new SpongeError('integrity');
135
+ }
136
+ }
137
+ function pinCurrent(p) {
138
+ try {
139
+ const r = describe(p.path, p.path, 512 * 1024 ** 2);
140
+ return r.bytes === p.bytes && r.sha256 === p.sha256;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ function current(record) {
147
+ return record.pins.every(pinCurrent);
148
+ }
149
+ /** Why the registered worker no longer matches this installed Sponge. Any entry means it cannot (or should not) start;
150
+ * `worker install` repairs every one of them by reinstalling from the current package. */
151
+ function drift(record, env, log) {
152
+ const result = new Set();
153
+ for (const pin of record.pins)
154
+ if (!pinCurrent(pin))
155
+ result.add(pin.path === record.node ? 'node-changed' : 'package-changed');
156
+ if (record.version !== env.version)
157
+ result.add('version-changed');
158
+ if (record.cli !== env.cli)
159
+ result.add('cli-moved');
160
+ if (sha256(agentPlist({ ...record, log })) !== record.plistHash)
161
+ result.add('plist-outdated');
162
+ return [...result];
163
+ }
164
+ const driftRepair = 'Run sponge worker install to reinstall the worker for the current installed package and Node.';
165
+ function xml(value) {
166
+ return value
167
+ .replaceAll('&', '&amp;')
168
+ .replaceAll('<', '&lt;')
169
+ .replaceAll('>', '&gt;')
170
+ .replaceAll('"', '&quot;')
171
+ .replaceAll("'", '&apos;');
172
+ }
173
+ export function agentPlist(record) {
174
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(record.label)}</string><key>ProgramArguments</key><array><string>${xml(record.node)}</string><string>${xml(record.launcher)}</string></array><key>RunAtLoad</key><true/><key>StartInterval</key><integer>30</integer><key>ProcessType</key><string>Background</string><key>ExitTimeOut</key><integer>60</integer><key>Umask</key><integer>63</integer><key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/bin:/bin</string><key>LANG</key><string>en_US.UTF-8</string><key>HRANESS_NO_UPDATE</key><string>1</string></dict><key>StandardOutPath</key><string>${xml(record.log)}</string><key>StandardErrorPath</key><string>${xml(record.log)}</string></dict></plist>\n`;
175
+ }
64
176
  function pinPackage(env) {
65
177
  const cli = realpathSync(env.cli), root = dirname(dirname(cli));
66
178
  if (cli !== join(root, 'bin', 'sponge.js'))
67
- throw new StoreError('invalid-input');
179
+ throw new SpongeError('invalid-input');
68
180
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
69
181
  if (pkg.name !== '@hraness/sponge' || pkg.version !== env.version || !/^\d+\.\d+\.\d+$/u.test(env.version))
70
- throw new StoreError('invalid-input');
182
+ throw new SpongeError('invalid-input');
71
183
  const paths = [env.node, cli, join(root, 'package.json')];
72
184
  let total = 0;
73
- const visit = (directory) => { safeDirectory(directory); for (const name of readdirSync(directory).sort()) {
74
- const path = join(directory, name), stat = lstatSync(path);
75
- if (stat.isSymbolicLink())
76
- throw new StoreError('integrity');
77
- if (stat.isDirectory())
78
- visit(path);
79
- else {
80
- regularFile(path);
81
- if (paths.length >= 10000 || total + stat.size > 64 * 1024 * 1024)
82
- throw new StoreError('limit-exceeded');
83
- total += stat.size;
84
- paths.push(path);
185
+ const visit = (directory) => {
186
+ safeDirectory(directory);
187
+ for (const name of readdirSync(directory).sort()) {
188
+ const path = join(directory, name), stat = lstatSync(path);
189
+ if (stat.isSymbolicLink())
190
+ throw new SpongeError('integrity');
191
+ if (stat.isDirectory())
192
+ visit(path);
193
+ else {
194
+ regularFile(path);
195
+ if (paths.length >= 10000 || total + stat.size > 64 * 1024 * 1024)
196
+ throw new SpongeError('limit-exceeded');
197
+ total += stat.size;
198
+ paths.push(path);
199
+ }
85
200
  }
86
- } };
201
+ };
87
202
  visit(join(root, 'dist'));
88
203
  const catalog = join(root, 'native', 'runtime-manifest.json');
89
204
  let catalogPresent = false;
@@ -99,44 +214,98 @@ function pinPackage(env) {
99
214
  safeDirectory(dirname(catalog));
100
215
  regularFile(catalog);
101
216
  if (lstatSync(catalog).size > 16384 || paths.length >= 10000)
102
- throw new StoreError('limit-exceeded');
217
+ throw new SpongeError('limit-exceeded');
103
218
  paths.push(catalog);
104
219
  }
105
- return paths.map(path => { const record = describe(path, path, 512 * 1024 ** 2); return { path, bytes: record.bytes, sha256: record.sha256 }; });
106
- }
107
- function launcher(record) { return `// Sponge pinned service launcher. Reinstall the worker after upgrading its package.\nimport {openSync,readSync,closeSync,fstatSync,constants} from 'node:fs';\nimport {createHash} from 'node:crypto';\nimport {pathToFileURL} from 'node:url';\nconst pins=${JSON.stringify(record.pins)};\nfor(const p of pins){let fd;try{fd=openSync(p.path,constants.O_RDONLY|constants.O_NOFOLLOW);const s=fstatSync(fd);if(!s.isFile()||s.nlink!==1||s.size!==p.bytes)throw Error();const h=createHash('sha256'),b=Buffer.alloc(65536);let n;while((n=readSync(fd,b,0,b.length,null)))h.update(b.subarray(0,n));if(h.digest('hex')!==p.sha256)throw Error();}catch{process.stderr.write('Sponge worker installation changed. Run sponge worker uninstall, then sponge worker install.\\n');process.exit(78);}finally{if(fd!==undefined)closeSync(fd);}}\nprocess.env.SPONGE_SERVICE_INSTALLATION=${JSON.stringify(record.id)};process.env.HRANESS_NO_UPDATE='1';process.argv=[process.execPath,${JSON.stringify(record.cli)},'--library',${JSON.stringify(record.library)},'worker','serve',...${JSON.stringify(record.language ? ['--language', record.language] : [])}];\nawait import(pathToFileURL(${JSON.stringify(record.cli)}).href);\n`; }
108
- async function loaded(env, target) { const result = await env.launchctl(['print', target]); if (result.code === 0)
109
- return true; if (result.code === 113)
110
- return false; throw new StoreError('unavailable'); }
111
- export async function serviceStatus(store, env) { const p = paths(store, env), record = installation(store, env); if (!record)
112
- return { installed: false }; verifyOwnedFiles(record); const stale = !current(record); return { installed: true, loaded: await loaded(env, p.target), version: record.version, language: record.language, stale, ...(stale ? { repair: 'Run sponge worker uninstall, then sponge worker install from the current installed package.' } : {}) }; }
220
+ return paths.map((path) => {
221
+ const record = describe(path, path, 512 * 1024 ** 2);
222
+ return { path, bytes: record.bytes, sha256: record.sha256 };
223
+ });
224
+ }
225
+ function launcher(record) {
226
+ return `// Sponge pinned service launcher. Reinstall the worker after upgrading its package.\nimport {openSync,readSync,closeSync,fstatSync,lstatSync,renameSync,constants} from 'node:fs';\nimport {createHash} from 'node:crypto';\nimport {pathToFileURL} from 'node:url';\nconst log=${JSON.stringify(record.log)};\ntry{const s=lstatSync(log);if(s.isFile()&&s.size>=1048576)renameSync(log,log+'.1');}catch{}\nconst pins=${JSON.stringify(record.pins)};\nfor(const p of pins){let fd;try{fd=openSync(p.path,constants.O_RDONLY|constants.O_NOFOLLOW);const s=fstatSync(fd);if(!s.isFile()||s.nlink!==1||s.size!==p.bytes)throw Error();const h=createHash('sha256'),b=Buffer.alloc(65536);let n;while((n=readSync(fd,b,0,b.length,null)))h.update(b.subarray(0,n));if(h.digest('hex')!==p.sha256)throw Error();}catch{process.stderr.write(new Date().toISOString()+' Sponge worker installation changed: '+(p===pins[0]?'Node executable':'package files')+'. Run sponge worker install to repair.\\n');process.exit(78);}finally{if(fd!==undefined)closeSync(fd);}}\nprocess.env.SPONGE_SERVICE_INSTALLATION=${JSON.stringify(record.id)};process.env.HRANESS_NO_UPDATE='1';process.argv=[process.execPath,${JSON.stringify(record.cli)},'--library',${JSON.stringify(record.library)},'worker','serve',...${JSON.stringify(record.language ? ['--language', record.language] : [])}];\nawait import(pathToFileURL(${JSON.stringify(record.cli)}).href);\n`;
227
+ }
228
+ async function loaded(env, target) {
229
+ const result = await env.launchctl(['print', target]);
230
+ if (result.code === 0)
231
+ return true;
232
+ if (result.code === 113)
233
+ return false;
234
+ throw new SpongeError('unavailable');
235
+ }
236
+ async function launchState(env, target) {
237
+ const result = await env.launchctl(['print', target]);
238
+ if (result.code === 113)
239
+ return { loaded: false };
240
+ if (result.code !== 0)
241
+ throw new SpongeError('unavailable');
242
+ const exit = /^\s*last exit code = (-?\d{1,5})\b/mu.exec(result.stdout);
243
+ return { loaded: true, ...(exit ? { lastExit: Number(exit[1]) } : {}) };
244
+ }
245
+ export async function serviceStatus(store, env) {
246
+ const p = paths(store, env), record = installation(store, env);
247
+ if (!record)
248
+ return { installed: false };
249
+ verifyOwnedFiles(record);
250
+ const found = drift(record, env, p.log);
251
+ return {
252
+ installed: true,
253
+ ...(await launchState(env, p.target)),
254
+ version: record.version,
255
+ language: record.language,
256
+ stale: found.length > 0,
257
+ ...(found.length ? { drift: found, repair: driftRepair } : {}),
258
+ log: p.log,
259
+ };
260
+ }
113
261
  export async function installService(store, env, options = {}) {
114
262
  if (options.language !== undefined && options.language !== 'en')
115
- throw new StoreError('invalid-input');
263
+ throw new SpongeError('invalid-input');
116
264
  const p = paths(store, env);
117
265
  safeDirectory(p.root, true);
118
266
  const previous = installation(store, env);
267
+ // Idempotent: a current registration is kept (and reloaded if it was booted out); a drifted one is uninstalled through
268
+ // the verified path (a foreign plist still fails closed) and installed again from the current package and Node.
119
269
  if (previous) {
120
270
  if (previous.language !== (options.language ?? null))
121
- throw new StoreError('invalid-input');
122
- return serviceStatus(store, env);
271
+ throw new SpongeError('invalid-input');
272
+ verifyOwnedFiles(previous);
273
+ if (!drift(previous, env, p.log).length) {
274
+ if (!(await loaded(env, p.target)) && (await env.launchctl(['bootstrap', `gui/${env.uid}`, p.plist])).code !== 0)
275
+ throw new SpongeError('unavailable');
276
+ return serviceStatus(store, env);
277
+ }
278
+ await uninstallService(store, env);
123
279
  }
124
280
  if (await loaded(env, p.target))
125
- throw new StoreError('owner-busy');
281
+ throw new SpongeError('owner-busy');
126
282
  safeDirectory(p.agents, true);
127
283
  if (existsSync(p.plist))
128
- throw new StoreError('integrity');
284
+ throw new SpongeError('integrity');
129
285
  const id = randomUUID(), pins = pinPackage(env), launcherPath = join(p.root, 'launchers', env.version + '-' + id, 'run.mjs');
130
286
  safeDirectory(dirname(launcherPath), true);
131
- const base = { language: options.language ?? null, schemaVersion: 1, id, label: p.label, uid: env.uid, version: env.version, node: env.node, cli: env.cli, library: store.root, plist: p.plist, launcher: launcherPath, pins };
132
- const code = launcher(base), plist = agentPlist(base);
287
+ const base = {
288
+ language: options.language ?? null,
289
+ schemaVersion: 1,
290
+ id,
291
+ label: p.label,
292
+ uid: env.uid,
293
+ version: env.version,
294
+ node: env.node,
295
+ cli: env.cli,
296
+ library: store.root,
297
+ plist: p.plist,
298
+ launcher: launcherPath,
299
+ pins,
300
+ };
301
+ const code = launcher({ ...base, log: p.log }), plist = agentPlist({ ...base, log: p.log });
133
302
  writePrivateExclusive(launcherPath, code);
134
303
  writePrivateExclusive(p.plist, plist);
135
- const record = { ...base, launcherHash: digest(code), plistHash: digest(plist) };
304
+ const record = { ...base, launcherHash: sha256(code), plistHash: sha256(plist) };
136
305
  writePrivateExclusive(p.record, JSON.stringify(record) + '\n');
137
306
  const result = await env.launchctl(['bootstrap', `gui/${env.uid}`, p.plist]);
138
307
  if (result.code !== 0)
139
- throw new StoreError('unavailable');
308
+ throw new SpongeError('unavailable');
140
309
  return serviceStatus(store, env);
141
310
  }
142
311
  export async function uninstallService(store, env) {
@@ -147,18 +316,32 @@ export async function uninstallService(store, env) {
147
316
  if (await loaded(env, p.target)) {
148
317
  const result = await env.launchctl(['bootout', p.target]);
149
318
  if (result.code !== 0 && result.code !== 113)
150
- throw new StoreError('unavailable');
319
+ throw new SpongeError('unavailable');
151
320
  }
152
321
  for (let attempt = 0; attempt < 50; attempt++) {
153
322
  let alive = false;
154
323
  const run = join(p.root, 'run.json');
155
324
  if (existsSync(run)) {
156
325
  const r = exact(readJson(run), ['schemaVersion', 'installationId', 'runId', 'identity', 'state']), identity = exact(r.identity, ['pid', 'uid', 'started']);
157
- if (r.schemaVersion !== 1 || r.installationId !== record.id || typeof r.runId !== 'string' || !/^[a-f0-9-]{36}$/u.test(r.runId) || !['running', 'stopped', 'cleanup-required'].includes(String(r.state)) || !Number.isSafeInteger(identity.pid) || Number(identity.pid) <= 1 || identity.uid !== env.uid || typeof identity.started !== 'string' || !identity.started || identity.started.length > 256)
158
- throw new StoreError('integrity');
326
+ if (r.schemaVersion !== 1 ||
327
+ r.installationId !== record.id ||
328
+ typeof r.runId !== 'string' ||
329
+ !/^[a-f0-9-]{36}$/u.test(r.runId) ||
330
+ !['running', 'stopped', 'cleanup-required'].includes(String(r.state)) ||
331
+ !Number.isSafeInteger(identity.pid) ||
332
+ Number(identity.pid) <= 1 ||
333
+ identity.uid !== env.uid ||
334
+ typeof identity.started !== 'string' ||
335
+ !identity.started ||
336
+ identity.started.length > 256)
337
+ throw new SpongeError('integrity');
159
338
  alive = await env.alive(identity);
160
339
  }
161
- if (!await loaded(env, p.target) && !alive && store.ownerSnapshot() === null && store.pendingCleanupCount() === 0 && await recoverServiceLifetime(store, record.id, env.alive)) {
340
+ if (!(await loaded(env, p.target)) &&
341
+ !alive &&
342
+ store.queue.ownerSnapshot() === null &&
343
+ store.queue.pendingCleanup() === 0 &&
344
+ (await recoverServiceLifetime(store, record.id, env.alive))) {
162
345
  verifyOwnedFiles(record);
163
346
  unlinkSync(record.plist);
164
347
  unlinkSync(record.launcher);
@@ -172,8 +355,14 @@ export async function uninstallService(store, env) {
172
355
  }
173
356
  await env.pause(100);
174
357
  }
175
- throw Object.assign(new StoreError('owner-busy'), { reason: 'worker-cleanup-required' });
358
+ throw Object.assign(new SpongeError('owner-busy'), { reason: 'worker-cleanup-required' });
359
+ }
360
+ export function installedServiceId(store, env) {
361
+ const record = installation(store, env);
362
+ if (!record || process.env.SPONGE_SERVICE_INSTALLATION !== record.id)
363
+ throw new SpongeError('invalid-input');
364
+ verifyOwnedFiles(record);
365
+ if (!current(record))
366
+ throw new SpongeError('unavailable');
367
+ return record.id;
176
368
  }
177
- export function installedServiceId(store, env) { const record = installation(store, env); if (!record || process.env.SPONGE_SERVICE_INSTALLATION !== record.id)
178
- throw new StoreError('invalid-input'); verifyOwnedFiles(record); if (!current(record))
179
- throw new StoreError('unavailable'); return record.id; }
@@ -1,20 +1,46 @@
1
1
  import { join } from 'node:path';
2
2
  import { lstat, link, unlink } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { readJournal, writeJournal, syncDir } from '../worker/journal.js';
5
- import { StoreError } from '../store/index.js';
6
- const pending = () => new StoreError('owner-busy');
7
- function id(value) { return typeof value === 'string' && /^[a-f0-9-]{36}$/u.test(value); }
8
- async function same(a, b) { const [x, y] = await Promise.all([lstat(a), lstat(b)]); if (!x.isFile() || x.isSymbolicLink() || !y.isFile() || y.isSymbolicLink() || x.dev !== y.dev || x.ino !== y.ino)
9
- throw pending(); }
10
- async function read(path) { const r = await readJournal(path); if (!r || Object.keys(r).sort().join(',') !== 'id,identity,installationId,schemaVersion' || r.schemaVersion !== 1 || !id(r.id) || !id(r.installationId) || !r.identity || Object.keys(r.identity).sort().join(',') !== 'pid,started,uid' || !Number.isSafeInteger(r.identity.pid) || r.identity.pid <= 1 || !Number.isSafeInteger(r.identity.uid) || r.identity.uid < 0 || typeof r.identity.started !== 'string' || !r.identity.started || r.identity.started.length > 256)
11
- throw pending(); return r; }
4
+ import { readJournal, writeJournal } from '../worker/journal.js';
5
+ import { syncDirectoryAsync } from '../util/fsync.js';
6
+ import { exactKeys } from '../util/exact.js';
7
+ import { SpongeError } from '../errors.js';
8
+ const pending = () => new SpongeError('owner-busy');
9
+ function id(value) {
10
+ return typeof value === 'string' && /^[a-f0-9-]{36}$/u.test(value);
11
+ }
12
+ async function same(a, b) {
13
+ const [x, y] = await Promise.all([lstat(a), lstat(b)]).catch((error) => {
14
+ if (error.code === 'ENOENT')
15
+ throw pending();
16
+ throw error;
17
+ });
18
+ if (!x.isFile() || x.isSymbolicLink() || !y.isFile() || y.isSymbolicLink() || x.dev !== y.dev || x.ino !== y.ino)
19
+ throw pending();
20
+ }
21
+ async function read(path) {
22
+ const busy = () => {
23
+ throw pending();
24
+ }, r = exactKeys(await readJournal(path), ['id', 'identity', 'installationId', 'schemaVersion'], busy), p = exactKeys(r.identity, ['pid', 'started', 'uid'], busy);
25
+ if (r.schemaVersion !== 1 ||
26
+ !id(r.id) ||
27
+ !id(r.installationId) ||
28
+ !Number.isSafeInteger(p.pid) ||
29
+ p.pid <= 1 ||
30
+ !Number.isSafeInteger(p.uid) ||
31
+ p.uid < 0 ||
32
+ typeof p.started !== 'string' ||
33
+ !p.started ||
34
+ p.started.length > 256)
35
+ throw pending();
36
+ return r;
37
+ }
12
38
  export async function acquireServiceLifetime(root, installationId, identity) {
13
39
  const value = { schemaVersion: 1, id: randomUUID(), installationId, identity }, file = join(root, `lifetime-${value.id}.json`), lock = join(root, 'lifetime.lock');
14
40
  await writeJournal(file, value);
15
41
  try {
16
42
  await link(file, lock);
17
- await syncDir(root);
43
+ await syncDirectoryAsync(root);
18
44
  }
19
45
  catch (error) {
20
46
  await unlink(file);
@@ -22,7 +48,14 @@ export async function acquireServiceLifetime(root, installationId, identity) {
22
48
  throw pending();
23
49
  throw error;
24
50
  }
25
- return { release: async () => { await same(file, lock); await unlink(lock); await unlink(file); await syncDir(root); } };
51
+ return {
52
+ release: async () => {
53
+ await same(file, lock);
54
+ await unlink(lock);
55
+ await unlink(file);
56
+ await syncDirectoryAsync(root);
57
+ },
58
+ };
26
59
  }
27
60
  /** Called only after bootout or a failed prior run; never signal a recorded PID. */
28
61
  export async function recoverServiceLifetime(store, installationId, alive) {
@@ -36,7 +69,7 @@ export async function recoverServiceLifetime(store, installationId, alive) {
36
69
  return true;
37
70
  throw error;
38
71
  }
39
- if (value.installationId !== installationId || await alive(value.identity))
72
+ if (value.installationId !== installationId || (await alive(value.identity)))
40
73
  return false;
41
74
  const file = join(root, `lifetime-${value.id}.json`), guard = join(root, 'lifetime-recovery.lock');
42
75
  await same(file, lock);
@@ -50,10 +83,10 @@ export async function recoverServiceLifetime(store, installationId, alive) {
50
83
  }
51
84
  try {
52
85
  await same(file, lock);
53
- if (await alive(value.identity) || store.ownerSnapshot() !== null || store.pendingCleanupCount() > 0)
86
+ if ((await alive(value.identity)) || store.queue.ownerSnapshot() !== null || store.queue.pendingCleanup() > 0)
54
87
  return false;
55
88
  await unlink(lock);
56
- await syncDir(root);
89
+ await syncDirectoryAsync(root);
57
90
  return true;
58
91
  }
59
92
  finally {
@@ -68,6 +101,6 @@ export async function recoverServiceLifetime(store, installationId, alive) {
68
101
  else
69
102
  throw error;
70
103
  }
71
- await syncDir(root);
104
+ await syncDirectoryAsync(root);
72
105
  }
73
106
  }
@@ -1,8 +1,8 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
2
  import { type ProcessIdentity } from '../worker/identity.js';
3
3
  export type ServiceEvent = 'started' | 'idle' | 'drained' | 'busy' | 'repair-required' | 'stopped';
4
- /** Fixed event vocabulary; source URLs, credentials, content and raw errors never enter logs. */
5
- export declare function serviceLog(root: string, event: ServiceEvent): void;
4
+ /** Fixed event vocabulary plus a stable error code token; source URLs, credentials, content and raw errors never enter logs. */
5
+ export declare function serviceLog(root: string, event: ServiceEvent, code?: unknown): void;
6
6
  export declare function idleWait(ms: number, signal: AbortSignal): Promise<void>;
7
7
  export interface ServiceLoopOptions {
8
8
  signal: AbortSignal;
@@ -10,7 +10,7 @@ export interface ServiceLoopOptions {
10
10
  drain: (signal: AbortSignal) => Promise<readonly unknown[]>;
11
11
  idleMs?: number;
12
12
  wait?: (ms: number, signal: AbortSignal) => Promise<void>;
13
- log?: (event: ServiceEvent) => void;
13
+ log?: (event: ServiceEvent, code?: unknown) => void;
14
14
  onIdle?: () => Promise<void>;
15
15
  identity?: () => Promise<ProcessIdentity | null>;
16
16
  }