@hraness/sponge 0.3.5 → 0.4.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.
- package/README.md +16 -7
- package/dist/migrations/011.d.ts +3 -0
- package/dist/migrations/011.js +9 -0
- package/dist/migrations/index.js +2 -0
- package/dist/src/cli/commands.js +59 -7
- package/dist/src/cli/runtimes.js +6 -2
- package/dist/src/cli/worker.js +5 -1
- package/dist/src/digest/endpoint.d.ts +3 -2
- package/dist/src/digest/endpoint.js +3 -2
- package/dist/src/digest/pipeline.d.ts +29 -4
- package/dist/src/digest/pipeline.js +125 -21
- package/dist/src/digest/runner.d.ts +33 -0
- package/dist/src/digest/runner.js +82 -0
- package/dist/src/digest/server.d.ts +40 -0
- package/dist/src/digest/server.js +166 -0
- package/dist/src/errors.d.ts +2 -0
- package/dist/src/errors.js +4 -0
- package/dist/src/importers/custody.d.ts +2 -5
- package/dist/src/importers/custody.js +3 -51
- package/dist/src/models/artifacts.d.ts +5 -0
- package/dist/src/models/artifacts.js +92 -17
- package/dist/src/models/digest.d.ts +64 -0
- package/dist/src/models/digest.js +122 -0
- package/dist/src/portability/database.js +2 -0
- package/dist/src/search/query.js +7 -54
- package/dist/src/store/digest-jobs.d.ts +58 -0
- package/dist/src/store/digest-jobs.js +168 -0
- package/dist/src/store/library.d.ts +3 -0
- package/dist/src/store/library.js +5 -1
- package/dist/src/store/queue.d.ts +9 -5
- package/dist/src/store/queue.js +14 -9
- package/dist/src/store/revisions.js +4 -0
- package/dist/src/worker/custody.d.ts +5 -0
- package/dist/src/worker/custody.js +7 -4
- package/dist/src/worker/transient.d.ts +17 -0
- package/dist/src/worker/transient.js +60 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { inspectDigest } from '../models/digest.js';
|
|
2
|
+
import { withDigestServer } from './server.js';
|
|
3
|
+
import { createDigest } from './pipeline.js';
|
|
4
|
+
import { codeOf } from '../errors.js';
|
|
5
|
+
const minute = 60000;
|
|
6
|
+
/** How long ready jobs wait when drafting cannot start, by cause. Nothing reached the model, so no attempt is counted. */
|
|
7
|
+
const postponeMs = {
|
|
8
|
+
'model-busy': 5 * minute,
|
|
9
|
+
'model-unavailable': 30 * minute,
|
|
10
|
+
'runtime-missing': 60 * minute,
|
|
11
|
+
unsupported: 60 * minute,
|
|
12
|
+
};
|
|
13
|
+
const at = (ms) => new Date(Date.now() + ms).toISOString();
|
|
14
|
+
/**
|
|
15
|
+
* Draft queued digests with one Sponge-owned server per drain (`withDigestServer`), claiming jobs under the worker lease
|
|
16
|
+
* until the queue is empty, the budget is spent or the signal aborts. Never throws for a missing runtime, a busy GPU or
|
|
17
|
+
* a server that would not start: the ready jobs are postponed with that reason, so capture and indexing keep running.
|
|
18
|
+
* `cleanup-required` and `owner-busy` still throw, as the service loop expects.
|
|
19
|
+
*/
|
|
20
|
+
export async function drainDigestQueue(store, options) {
|
|
21
|
+
if (options.signal?.aborted || !store.digests.due())
|
|
22
|
+
return [];
|
|
23
|
+
const settings = store.digests.settings(), results = [], deadline = Date.now() + (options.budgetMs ?? 10 * minute), create = options.create ?? createDigest;
|
|
24
|
+
const postpone = (code) => [
|
|
25
|
+
{ ok: false, code, postponed: store.digests.postpone(code, at(postponeMs[code] ?? 6 * 60 * minute)) },
|
|
26
|
+
];
|
|
27
|
+
let runtime;
|
|
28
|
+
try {
|
|
29
|
+
runtime = await (options.inspect ?? inspectDigest)(options.nativeRoot, settings.model ?? undefined);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return postpone(codeOf(error));
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await withDigestServer(store.root, runtime, async (model, { incarnation }) => {
|
|
36
|
+
const owner = store.queue.acquireOwner(incarnation);
|
|
37
|
+
try {
|
|
38
|
+
while (!options.signal?.aborted && Date.now() < deadline) {
|
|
39
|
+
const claim = store.digests.claim(owner);
|
|
40
|
+
if (!claim)
|
|
41
|
+
break;
|
|
42
|
+
try {
|
|
43
|
+
const { derivation, duplicate } = await create(store, claim.itemId, model, { signal: options.signal });
|
|
44
|
+
store.digests.finish(claim, { derivationId: derivation.id });
|
|
45
|
+
results.push({ ok: true, jobId: claim.jobId, digestId: derivation.id, duplicate });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = codeOf(error);
|
|
49
|
+
if (code === 'cleanup-required')
|
|
50
|
+
throw error;
|
|
51
|
+
// A cancelled draft goes back to the queue as it was; a server that stopped answering ends the drain.
|
|
52
|
+
if (options.signal?.aborted)
|
|
53
|
+
store.digests.finish(claim, {
|
|
54
|
+
reason: 'cancelled',
|
|
55
|
+
retryable: true,
|
|
56
|
+
countAttempt: false,
|
|
57
|
+
notBefore: at(0),
|
|
58
|
+
});
|
|
59
|
+
else
|
|
60
|
+
store.digests.finish(claim, { reason: code, retryable: code === 'model-unavailable' });
|
|
61
|
+
results.push({ ok: false, jobId: claim.jobId, code });
|
|
62
|
+
if (options.signal?.aborted || code === 'model-unavailable')
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
store.queue.releaseOwner(owner);
|
|
69
|
+
}
|
|
70
|
+
}, { ...options.server, signal: options.signal });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const code = codeOf(error);
|
|
74
|
+
if (code === 'cleanup-required' || code === 'owner-busy')
|
|
75
|
+
throw error;
|
|
76
|
+
// Before any claim, the server would not start or verify. After one, every job is settled already.
|
|
77
|
+
if (!results.length)
|
|
78
|
+
return postpone(code);
|
|
79
|
+
results.push({ ok: false, code });
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ProcessRunner } from '../worker/supervisor.js';
|
|
2
|
+
import { type DigestRuntime } from '../models/digest.js';
|
|
3
|
+
import type { DigestModel } from './pipeline.js';
|
|
4
|
+
/**
|
|
5
|
+
* The Sponge-owned digest server: the verified llama.cpp archive is unpacked into the attempt's scratch root and
|
|
6
|
+
* `llama-server` runs under `sandbox-exec` with outbound connections other than loopback denied, bound to 127.0.0.1
|
|
7
|
+
* on a free port, through the caller's process runner (the worker supervisor), so the server's process group is
|
|
8
|
+
* collected and proved like any helper even if this process dies. Captured text never leaves the machine.
|
|
9
|
+
*/
|
|
10
|
+
export declare const sandboxProfile = "(version 1) (allow default) (deny network-outbound (remote ip \"*:*\")) (allow network-outbound (remote ip \"localhost:*\"))";
|
|
11
|
+
/** Other `llama-server` processes in `ps -axo pid=,command=` output, with the model each loaded when it names one. */
|
|
12
|
+
export declare function otherServers(ps: string, self?: number): {
|
|
13
|
+
pid: number;
|
|
14
|
+
model?: string;
|
|
15
|
+
}[];
|
|
16
|
+
/** An ephemeral loopback port; the kernel picks one that is free now. */
|
|
17
|
+
export declare function freePort(): Promise<number>;
|
|
18
|
+
export interface DigestServerOptions {
|
|
19
|
+
runProcess: ProcessRunner;
|
|
20
|
+
/** A private directory owned by the caller's attempt; removed by the caller after `stop`. */
|
|
21
|
+
scratchRoot: string;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
ctx?: number;
|
|
24
|
+
readyTimeoutMs?: number;
|
|
25
|
+
/** Longest a server may run before the runner stops it. */
|
|
26
|
+
lifetimeMs?: number;
|
|
27
|
+
processes?: () => Promise<string>;
|
|
28
|
+
/** The executable inside the archive's directory; tests substitute a fake. */
|
|
29
|
+
executable?: string;
|
|
30
|
+
sandbox?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run `fn` against a Sponge-owned server for `runtime`, held as a `digest-server` attempt under worker custody: the
|
|
34
|
+
* server is stopped and its process group proved collected before custody is released.
|
|
35
|
+
*/
|
|
36
|
+
export declare function withDigestServer<T>(libraryRoot: string, runtime: DigestRuntime, fn: (model: DigestModel, custody: {
|
|
37
|
+
incarnation: string;
|
|
38
|
+
}) => Promise<T>, options?: {
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
} & Partial<Omit<DigestServerOptions, 'runProcess' | 'scratchRoot'>>): Promise<T>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { lstat, readdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { directory } from '../worker/journal.js';
|
|
6
|
+
import { llamaRuntimePin } from '../models/digest.js';
|
|
7
|
+
import { withTransientAttempt } from '../worker/transient.js';
|
|
8
|
+
import { connectEndpoint } from './endpoint.js';
|
|
9
|
+
import { SpongeError } from '../errors.js';
|
|
10
|
+
/**
|
|
11
|
+
* The Sponge-owned digest server: the verified llama.cpp archive is unpacked into the attempt's scratch root and
|
|
12
|
+
* `llama-server` runs under `sandbox-exec` with outbound connections other than loopback denied, bound to 127.0.0.1
|
|
13
|
+
* on a free port, through the caller's process runner (the worker supervisor), so the server's process group is
|
|
14
|
+
* collected and proved like any helper even if this process dies. Captured text never leaves the machine.
|
|
15
|
+
*/
|
|
16
|
+
export const sandboxProfile = '(version 1) (allow default) (deny network-outbound (remote ip "*:*")) (allow network-outbound (remote ip "localhost:*"))';
|
|
17
|
+
/** Other `llama-server` processes in `ps -axo pid=,command=` output, with the model each loaded when it names one. */
|
|
18
|
+
export function otherServers(ps, self = process.pid) {
|
|
19
|
+
return ps.split('\n').flatMap((line) => {
|
|
20
|
+
const m = /^\s*(\d+)\s+(\S*llama-server)(\s.*)?$/.exec(line);
|
|
21
|
+
if (!m || Number(m[1]) === self)
|
|
22
|
+
return [];
|
|
23
|
+
const model = /\s(?:-m|--model)\s+(\S+)/.exec(m[3] ?? '')?.[1];
|
|
24
|
+
return [{ pid: Number(m[1]), ...(model ? { model: model.split('/').pop() } : {}) }];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const listProcesses = () => new Promise((ok, fail) => execFile('/bin/ps', ['-axo', 'pid=,command='], { maxBuffer: 16 * 1024 ** 2 }, (e, out) => (e ? fail(e) : ok(out))));
|
|
28
|
+
/** An ephemeral loopback port; the kernel picks one that is free now. */
|
|
29
|
+
export function freePort() {
|
|
30
|
+
return new Promise((ok, fail) => {
|
|
31
|
+
const s = createServer();
|
|
32
|
+
s.once('error', fail);
|
|
33
|
+
s.listen(0, '127.0.0.1', () => {
|
|
34
|
+
const port = s.address().port;
|
|
35
|
+
s.close(() => ok(port));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Every unpacked entry is a file, directory or a symlink resolving inside `root`. */
|
|
40
|
+
async function confined(root, dir = root) {
|
|
41
|
+
for (const name of await readdir(dir)) {
|
|
42
|
+
const path = join(dir, name), s = await lstat(path);
|
|
43
|
+
if (s.isDirectory())
|
|
44
|
+
await confined(root, path);
|
|
45
|
+
else if (s.isSymbolicLink()) {
|
|
46
|
+
if (!(await realpath(path)).startsWith(root + '/'))
|
|
47
|
+
throw new SpongeError('integrity');
|
|
48
|
+
}
|
|
49
|
+
else if (!s.isFile())
|
|
50
|
+
throw new SpongeError('integrity');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
54
|
+
async function startDigestServer(runtime, o) {
|
|
55
|
+
const [other] = otherServers(await (o.processes ?? listProcesses)());
|
|
56
|
+
// Two loaded models exhaust GPU memory and every request fails; wait for the other server instead.
|
|
57
|
+
if (other)
|
|
58
|
+
throw new SpongeError('model-busy', { detail: { pid: other.pid } });
|
|
59
|
+
const unpacked = await directory(join(o.scratchRoot, 'llama')), env = { PATH: '/usr/bin:/bin', HOME: o.scratchRoot, TMPDIR: o.scratchRoot, LANG: 'en_US.UTF-8' };
|
|
60
|
+
const tar = await o.runProcess('/usr/bin/tar', ['-xzf', runtime.archive, '-C', unpacked, '--no-same-owner'], {
|
|
61
|
+
env,
|
|
62
|
+
signal: o.signal,
|
|
63
|
+
deadlineMs: 120000,
|
|
64
|
+
maxOutputBytes: 65536,
|
|
65
|
+
});
|
|
66
|
+
if (tar.stopped === 'cancelled')
|
|
67
|
+
throw new SpongeError('cancelled');
|
|
68
|
+
if (tar.cleanup !== 'settled')
|
|
69
|
+
throw new SpongeError('cleanup-required');
|
|
70
|
+
if (tar.code !== 0 || tar.stopped)
|
|
71
|
+
throw new SpongeError('integrity');
|
|
72
|
+
await confined(await realpath(unpacked));
|
|
73
|
+
const server = join(unpacked, llamaRuntimePin.directory, o.executable ?? 'llama-server'), s = await lstat(server).catch(() => undefined);
|
|
74
|
+
if (!s?.isFile() || !(s.mode & 0o100))
|
|
75
|
+
throw new SpongeError('integrity');
|
|
76
|
+
const port = await freePort(), args = [
|
|
77
|
+
'-m',
|
|
78
|
+
runtime.model,
|
|
79
|
+
'--alias',
|
|
80
|
+
runtime.pin.alias,
|
|
81
|
+
'--host',
|
|
82
|
+
'127.0.0.1',
|
|
83
|
+
'--port',
|
|
84
|
+
String(port),
|
|
85
|
+
'-c',
|
|
86
|
+
String(o.ctx ?? 16384),
|
|
87
|
+
'-np',
|
|
88
|
+
'1',
|
|
89
|
+
'--jinja',
|
|
90
|
+
'--reasoning',
|
|
91
|
+
'off',
|
|
92
|
+
'--offline',
|
|
93
|
+
'--no-webui',
|
|
94
|
+
'--log-disable',
|
|
95
|
+
];
|
|
96
|
+
const own = new AbortController(), signal = o.signal ? AbortSignal.any([o.signal, own.signal]) : own.signal, sandbox = o.sandbox ?? true;
|
|
97
|
+
let result;
|
|
98
|
+
const run = o
|
|
99
|
+
.runProcess(sandbox ? '/usr/bin/sandbox-exec' : server, sandbox ? ['-p', sandboxProfile, server, ...args] : args, {
|
|
100
|
+
env,
|
|
101
|
+
signal,
|
|
102
|
+
deadlineMs: o.lifetimeMs ?? 6 * 3600 * 1000,
|
|
103
|
+
maxOutputBytes: 4 * 1024 ** 2,
|
|
104
|
+
graceMs: 5000,
|
|
105
|
+
})
|
|
106
|
+
.then((r) => (result = r));
|
|
107
|
+
let stopping;
|
|
108
|
+
const endpoint = `http://127.0.0.1:${port}`, handle = {
|
|
109
|
+
endpoint,
|
|
110
|
+
model: runtime.pin.alias,
|
|
111
|
+
stop: () => (stopping ??= (async () => {
|
|
112
|
+
own.abort();
|
|
113
|
+
return (await run).cleanup;
|
|
114
|
+
})()),
|
|
115
|
+
};
|
|
116
|
+
const deadline = Date.now() + (o.readyTimeoutMs ?? 180000);
|
|
117
|
+
try {
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
if (o.signal?.aborted)
|
|
120
|
+
throw new SpongeError('cancelled');
|
|
121
|
+
if (result)
|
|
122
|
+
throw new SpongeError('model-unavailable', { detail: { exited: result.code } });
|
|
123
|
+
// `/health` can answer before the model is listed; ready means both.
|
|
124
|
+
const models = await fetch(`${endpoint}/health`, { redirect: 'error', signal: AbortSignal.timeout(2000) })
|
|
125
|
+
.then(async (r) => {
|
|
126
|
+
await r.body?.cancel().catch(() => undefined);
|
|
127
|
+
if (!r.ok)
|
|
128
|
+
return undefined;
|
|
129
|
+
const m = await fetch(`${endpoint}/v1/models`, { redirect: 'error', signal: AbortSignal.timeout(2000) });
|
|
130
|
+
return m.ok ? (await m.json()) : undefined;
|
|
131
|
+
})
|
|
132
|
+
.catch(() => undefined);
|
|
133
|
+
if (models?.data?.some((m) => m?.id === runtime.pin.alias))
|
|
134
|
+
return handle;
|
|
135
|
+
await sleep(200);
|
|
136
|
+
}
|
|
137
|
+
throw new SpongeError('model-unavailable', { detail: { ready: false } });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if ((await handle.stop()) === 'pending')
|
|
141
|
+
throw new SpongeError('cleanup-required', { cause: error });
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Run `fn` against a Sponge-owned server for `runtime`, held as a `digest-server` attempt under worker custody: the
|
|
147
|
+
* server is stopped and its process group proved collected before custody is released.
|
|
148
|
+
*/
|
|
149
|
+
export function withDigestServer(libraryRoot, runtime, fn, options = {}) {
|
|
150
|
+
return withTransientAttempt(libraryRoot, 'digest-server', async ({ scratchRoot, runProcess, incarnation }) => {
|
|
151
|
+
const server = await startDigestServer(runtime, { ...options, runProcess, scratchRoot });
|
|
152
|
+
let result;
|
|
153
|
+
try {
|
|
154
|
+
const model = await connectEndpoint({ endpoint: server.endpoint, model: server.model, signal: options.signal });
|
|
155
|
+
// The identity must be the pinned build, so a digest's input hash names what drafted it.
|
|
156
|
+
if (!model.identity.runtime.includes(String(llamaRuntimePin.build)))
|
|
157
|
+
throw new SpongeError('integrity');
|
|
158
|
+
result = await fn(model, { incarnation });
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if ((await server.stop()) === 'pending')
|
|
162
|
+
throw new SpongeError('cleanup-required');
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
});
|
|
166
|
+
}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ declare const parents: {
|
|
|
18
18
|
readonly 'no-capture': "invalid-input";
|
|
19
19
|
readonly 'draft-rejected': "unavailable";
|
|
20
20
|
readonly 'model-unavailable': "unavailable";
|
|
21
|
+
readonly 'model-busy': "unavailable";
|
|
22
|
+
readonly 'source-too-thin': "unavailable";
|
|
21
23
|
readonly 'parent-missing': "invalid-input";
|
|
22
24
|
readonly 'target-exists': "invalid-input";
|
|
23
25
|
};
|
package/dist/src/errors.js
CHANGED
|
@@ -32,6 +32,8 @@ const parents = {
|
|
|
32
32
|
'no-capture': 'invalid-input',
|
|
33
33
|
'draft-rejected': 'unavailable',
|
|
34
34
|
'model-unavailable': 'unavailable',
|
|
35
|
+
'model-busy': 'unavailable',
|
|
36
|
+
'source-too-thin': 'unavailable',
|
|
35
37
|
'parent-missing': 'invalid-input',
|
|
36
38
|
'target-exists': 'invalid-input',
|
|
37
39
|
};
|
|
@@ -83,6 +85,8 @@ export const hints = {
|
|
|
83
85
|
export const detailHints = {
|
|
84
86
|
'draft-rejected': 'The model draft failed the digest checks listed above, even after one repair. Drafting is deterministic, so the same capture and model fail again; refresh the item or use another model.',
|
|
85
87
|
'model-unavailable': 'The model server did not answer or returned an error. Check that it is still running and has free GPU memory (another loaded model can exhaust it), then retry.',
|
|
88
|
+
'source-too-thin': 'The capture holds too little text to digest: under 20 words of prose, or only an archive’s metadata. Refresh the item when a fuller capture is possible.',
|
|
89
|
+
'model-busy': 'Another llama-server is running, and two loaded models can exhaust GPU memory. Wait for it to exit, then retry.',
|
|
86
90
|
};
|
|
87
91
|
/** The one error type. `message` is the code, so bare `String(error)` never carries detail, paths or causes. */
|
|
88
92
|
export class SpongeError extends Error {
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type TransientAttempt } from '../worker/transient.js';
|
|
2
2
|
/** Read-only verification still owns subprocesses, including during an import preview. */
|
|
3
|
-
export declare function withImportVerification<T>(libraryRoot: string, verify: (context:
|
|
4
|
-
scratchRoot: string;
|
|
5
|
-
runProcess: ReturnType<typeof supervisedRunner>;
|
|
6
|
-
}) => Promise<T>): Promise<T>;
|
|
3
|
+
export declare function withImportVerification<T>(libraryRoot: string, verify: (context: Pick<TransientAttempt, 'scratchRoot' | 'runProcess'>) => Promise<T>): Promise<T>;
|
|
@@ -1,53 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
4
|
-
import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
|
|
5
|
-
import { directory, writeJournal, readJournal } from '../worker/journal.js';
|
|
6
|
-
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
7
|
-
import { supervisedRunner } from '../worker/supervisor.js';
|
|
8
|
-
import { SpongeError, codeOf } from '../errors.js';
|
|
9
|
-
const pending = () => new SpongeError('cleanup-required');
|
|
1
|
+
import { withTransientAttempt } from '../worker/transient.js';
|
|
10
2
|
/** Read-only verification still owns subprocesses, including during an import preview. */
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
let scratchRoot, unknown = false;
|
|
14
|
-
try {
|
|
15
|
-
const attempt = await directory(join(custody.directory, 'attempts', attemptId));
|
|
16
|
-
await writeJournal(join(attempt, 'import-verification.json'), {
|
|
17
|
-
schemaVersion: 1,
|
|
18
|
-
kind: 'import-verification',
|
|
19
|
-
attemptId,
|
|
20
|
-
ownerIncarnation: custody.incarnation,
|
|
21
|
-
});
|
|
22
|
-
scratchRoot = await directory(join(libraryRoot, 'staging', 'import-verification-' + attemptId));
|
|
23
|
-
await writeJournal(join(scratchRoot, 'ownership.json'), {
|
|
24
|
-
schemaVersion: 1,
|
|
25
|
-
kind: 'import-verification',
|
|
26
|
-
requestId: attemptId,
|
|
27
|
-
ownerIncarnation: custody.incarnation,
|
|
28
|
-
});
|
|
29
|
-
return await verify({
|
|
30
|
-
scratchRoot,
|
|
31
|
-
runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
unknown = codeOf(error) === 'cleanup-required';
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
finally {
|
|
39
|
-
if (unknown || !(await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId)))
|
|
40
|
-
throw pending();
|
|
41
|
-
if (scratchRoot) {
|
|
42
|
-
const marker = await readJournal(join(scratchRoot, 'ownership.json'));
|
|
43
|
-
if (marker.schemaVersion !== 1 ||
|
|
44
|
-
marker.kind !== 'import-verification' ||
|
|
45
|
-
marker.requestId !== attemptId ||
|
|
46
|
-
marker.ownerIncarnation !== custody.incarnation)
|
|
47
|
-
throw pending();
|
|
48
|
-
await rm(scratchRoot, { recursive: true });
|
|
49
|
-
await syncDirectoryAsync(join(libraryRoot, 'staging'));
|
|
50
|
-
}
|
|
51
|
-
await custody.release();
|
|
52
|
-
}
|
|
3
|
+
export function withImportVerification(libraryRoot, verify) {
|
|
4
|
+
return withTransientAttempt(libraryRoot, 'import-verification', ({ scratchRoot, runProcess }) => verify({ scratchRoot, runProcess }));
|
|
53
5
|
}
|
|
@@ -9,6 +9,8 @@ export interface ModelPackage {
|
|
|
9
9
|
id: string;
|
|
10
10
|
license: string;
|
|
11
11
|
artifacts: readonly ModelArtifact[];
|
|
12
|
+
/** Per-artifact and per-package byte limit; 2 GiB unless a package that needs more (a local LLM) says so. */
|
|
13
|
+
maximumBytes?: number;
|
|
12
14
|
}
|
|
13
15
|
export declare function modelSetupPlan(manifest: ModelPackage): {
|
|
14
16
|
id: string;
|
|
@@ -28,6 +30,9 @@ export declare function provisionModelPackage(cacheRoot: string, manifest: Model
|
|
|
28
30
|
signal?: AbortSignal;
|
|
29
31
|
fetch?: typeof fetch;
|
|
30
32
|
reuseOnly?: boolean;
|
|
33
|
+
/** Local files to adopt by artifact name instead of downloading; cloned or copied, verified, never modified. */
|
|
34
|
+
adopt?: Readonly<Record<string, string>>;
|
|
35
|
+
idleMs?: number;
|
|
31
36
|
}): Promise<Record<string, string>>;
|
|
32
37
|
/** The installed package for this manifest, by content, or null when none is. A package installed under an earlier
|
|
33
38
|
* URL-bound fingerprint is adopted once: its verified artifacts are hard-linked into the content-named directory under
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
-
import { chmod, lstat, mkdir, open, rename, rm, statfs, link, unlink, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, open, rename, rm, statfs, link, unlink, readdir, realpath, } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { processIdentity, identityAlive } from '../worker/identity.js';
|
|
@@ -8,9 +8,15 @@ import { acquireIdentityLock } from '../worker/lock.js';
|
|
|
8
8
|
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
9
9
|
import { privateRoot } from '../acquisition/files.js';
|
|
10
10
|
import { SpongeError, codeOf } from '../errors.js';
|
|
11
|
-
const
|
|
11
|
+
const defaultMaximumBytes = 2 * 1024 ** 3, packageCeiling = 64 * 1024 ** 3;
|
|
12
|
+
/** A download fails after this long without a byte, and after its size at 1 MB/s (at least 10 minutes) in all. */
|
|
13
|
+
const idleMs = 120000, downloadDeadline = (bytes) => Math.max(600000, bytes / 1000);
|
|
12
14
|
function validate(manifest) {
|
|
13
|
-
|
|
15
|
+
const maximumBytes = manifest.maximumBytes ?? defaultMaximumBytes;
|
|
16
|
+
if (!Number.isSafeInteger(maximumBytes) ||
|
|
17
|
+
maximumBytes < 1 ||
|
|
18
|
+
maximumBytes > packageCeiling ||
|
|
19
|
+
!/^[a-z0-9][a-z0-9.-]{0,127}$/.test(manifest.id) ||
|
|
14
20
|
!manifest.license ||
|
|
15
21
|
manifest.license.length > 256 ||
|
|
16
22
|
!manifest.artifacts.length ||
|
|
@@ -125,10 +131,14 @@ async function linksAccounted(root, stat) {
|
|
|
125
131
|
}
|
|
126
132
|
return count === stat.nlink;
|
|
127
133
|
}
|
|
134
|
+
/** Files this process already hashed, by inode and change time (which user space cannot set back): a worker
|
|
135
|
+
* re-verifies a 17 GB model by stat after the first time. Any change to the file, its links or its mode misses. */
|
|
136
|
+
const verified = new Map();
|
|
137
|
+
const statKey = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs, s.nlink, s.mode].join(':');
|
|
128
138
|
async function verifyArtifact(path, artifact, root) {
|
|
129
139
|
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
130
140
|
try {
|
|
131
|
-
const before = await file.stat();
|
|
141
|
+
const before = await file.stat(), key = statKey(await file.stat({ bigint: true }));
|
|
132
142
|
if (!before.isFile() ||
|
|
133
143
|
before.nlink < 1 ||
|
|
134
144
|
(before.nlink !== 1 && !(root && (await linksAccounted(root, before)))) ||
|
|
@@ -136,6 +146,9 @@ async function verifyArtifact(path, artifact, root) {
|
|
|
136
146
|
(before.mode & 0o077) !== 0 ||
|
|
137
147
|
(artifact.executable && (before.mode & 0o100) === 0))
|
|
138
148
|
throw new SpongeError('integrity');
|
|
149
|
+
if (verified.get(path) === `${key}:${artifact.sha256}`)
|
|
150
|
+
return;
|
|
151
|
+
verified.delete(path);
|
|
139
152
|
const hash = createHash('sha256'), buffer = Buffer.alloc(65536);
|
|
140
153
|
let bytes = 0;
|
|
141
154
|
for (;;) {
|
|
@@ -153,13 +166,14 @@ async function verifyArtifact(path, artifact, root) {
|
|
|
153
166
|
before.mtimeMs !== after.mtimeMs ||
|
|
154
167
|
before.nlink !== after.nlink)
|
|
155
168
|
throw new SpongeError('integrity');
|
|
169
|
+
verified.set(path, `${key}:${artifact.sha256}`);
|
|
156
170
|
}
|
|
157
171
|
finally {
|
|
158
172
|
await file.close();
|
|
159
173
|
}
|
|
160
174
|
}
|
|
161
175
|
/** Write one stream into a fresh staged file while hashing; the caller verifies against the manifest. */
|
|
162
|
-
async function writeStaged(path, artifact, chunks, signal) {
|
|
176
|
+
async function writeStaged(path, artifact, chunks, signal, onChunk) {
|
|
163
177
|
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
164
178
|
const hash = createHash('sha256');
|
|
165
179
|
let bytes = 0;
|
|
@@ -167,6 +181,7 @@ async function writeStaged(path, artifact, chunks, signal) {
|
|
|
167
181
|
for await (const chunk of chunks) {
|
|
168
182
|
if (signal?.aborted)
|
|
169
183
|
throw new SpongeError('cancelled');
|
|
184
|
+
onChunk?.();
|
|
170
185
|
bytes += chunk.length;
|
|
171
186
|
if (bytes > artifact.bytes)
|
|
172
187
|
throw new SpongeError('integrity');
|
|
@@ -410,6 +425,44 @@ export async function recoverModelPackage(cacheRoot, manifest) {
|
|
|
410
425
|
}
|
|
411
426
|
}
|
|
412
427
|
}
|
|
428
|
+
/** Adopt a local file the user named (a model they already downloaded): an APFS clone when the volume allows, else a
|
|
429
|
+
* copy, then verified against the manifest. The source is only read; a mismatch fails without downloading. */
|
|
430
|
+
async function adoptArtifact(root, stage, artifact, source, signal) {
|
|
431
|
+
if (signal?.aborted)
|
|
432
|
+
throw new SpongeError('cancelled');
|
|
433
|
+
const target = join(stage, artifact.name);
|
|
434
|
+
let real;
|
|
435
|
+
try {
|
|
436
|
+
real = await realpath(source);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
throw new SpongeError('invalid-input');
|
|
440
|
+
}
|
|
441
|
+
const s = await lstat(real);
|
|
442
|
+
if (!s.isFile() || real.startsWith(root + '/'))
|
|
443
|
+
throw new SpongeError('invalid-input');
|
|
444
|
+
if (s.size !== artifact.bytes)
|
|
445
|
+
throw new SpongeError('integrity');
|
|
446
|
+
try {
|
|
447
|
+
try {
|
|
448
|
+
await copyFile(real, target, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE_FORCE);
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (!['ENOTSUP', 'EXDEV', 'ENOSYS', 'EINVAL'].includes(error.code ?? ''))
|
|
452
|
+
throw error;
|
|
453
|
+
const disk = await statfs(root);
|
|
454
|
+
if (disk.bavail * disk.bsize < artifact.bytes + 512 * 1024 ** 2)
|
|
455
|
+
throw new SpongeError('limit-exceeded');
|
|
456
|
+
await copyFile(real, target, constants.COPYFILE_EXCL);
|
|
457
|
+
}
|
|
458
|
+
await chmod(target, artifact.executable ? 0o700 : 0o600);
|
|
459
|
+
await verifyArtifact(target, artifact, root);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
await rm(target, { force: true });
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
413
466
|
/** Explicit setup only. Never called by install hooks or by inference fallback, except `reuseOnly`: that adopts an
|
|
414
467
|
* installed package from verified local artifacts under the same setup lock and never reaches the network. */
|
|
415
468
|
export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
@@ -461,7 +514,7 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
|
461
514
|
// Reusable artifacts need no download space; a copy fallback rechecks space for itself.
|
|
462
515
|
let needed = 512 * 1024 ** 2;
|
|
463
516
|
for (const artifact of manifest.artifacts)
|
|
464
|
-
if (!(await reuseCandidates(root, artifact, plan.fingerprint)).length)
|
|
517
|
+
if (!options.adopt?.[artifact.name] && !(await reuseCandidates(root, artifact, plan.fingerprint)).length)
|
|
465
518
|
needed += artifact.bytes;
|
|
466
519
|
const disk = await statfs(root);
|
|
467
520
|
if (disk.bavail * disk.bsize < needed)
|
|
@@ -475,20 +528,42 @@ export async function provisionModelPackage(cacheRoot, manifest, options = {}) {
|
|
|
475
528
|
for (const artifact of manifest.artifacts) {
|
|
476
529
|
if (await reuseArtifact(root, stage, artifact, plan.fingerprint, options.signal))
|
|
477
530
|
continue;
|
|
531
|
+
const local = options.adopt?.[artifact.name];
|
|
532
|
+
if (local) {
|
|
533
|
+
await adoptArtifact(root, stage, artifact, local, options.signal);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
478
536
|
if (options.reuseOnly)
|
|
479
537
|
throw new SpongeError('unavailable');
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
await
|
|
489
|
-
|
|
538
|
+
const idle = new AbortController(), wait = options.idleMs ?? idleMs;
|
|
539
|
+
let timer = setTimeout(() => idle.abort(), wait);
|
|
540
|
+
const signal = AbortSignal.any([
|
|
541
|
+
idle.signal,
|
|
542
|
+
AbortSignal.timeout(downloadDeadline(artifact.bytes)),
|
|
543
|
+
...(options.signal ? [options.signal] : []),
|
|
544
|
+
]);
|
|
545
|
+
try {
|
|
546
|
+
const response = await (options.fetch ?? fetch)(artifact.url, { signal });
|
|
547
|
+
if (!response.ok || !response.body || (response.url && new URL(response.url).protocol !== 'https:'))
|
|
548
|
+
throw new SpongeError('unavailable');
|
|
549
|
+
const declared = response.headers.get('content-length');
|
|
550
|
+
if (declared !== null && Number(declared) !== artifact.bytes) {
|
|
551
|
+
await response.body.cancel();
|
|
552
|
+
throw new SpongeError('integrity');
|
|
553
|
+
}
|
|
554
|
+
await writeStaged(join(stage, artifact.name), artifact, response.body, signal, () => {
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
timer = setTimeout(() => idle.abort(), wait);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (idle.signal.aborted && !options.signal?.aborted)
|
|
561
|
+
throw new SpongeError('limit-exceeded', { cause: error });
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
clearTimeout(timer);
|
|
490
566
|
}
|
|
491
|
-
await writeStaged(join(stage, artifact.name), artifact, response.body, signal);
|
|
492
567
|
}
|
|
493
568
|
if (options.signal?.aborted)
|
|
494
569
|
throw new SpongeError('cancelled');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ModelPackage } from './artifacts.js';
|
|
2
|
+
/**
|
|
3
|
+
* The local digest runtime: upstream llama.cpp's macOS arm64 release build and one pinned GGUF, installed together as
|
|
4
|
+
* one model package (one fingerprint per model, so the shared archive hard-links across them). Pins live in source,
|
|
5
|
+
* not the per-release native catalog: they are third-party bytes that do not change with Sponge releases.
|
|
6
|
+
*/
|
|
7
|
+
export interface DigestModelPin {
|
|
8
|
+
readonly alias: string;
|
|
9
|
+
readonly file: string;
|
|
10
|
+
readonly bytes: number;
|
|
11
|
+
readonly sha256: string;
|
|
12
|
+
readonly repo: string;
|
|
13
|
+
readonly revision: string;
|
|
14
|
+
readonly base: string;
|
|
15
|
+
/** The smallest physical memory this model is chosen for by default. */
|
|
16
|
+
readonly minimumMemoryBytes: number;
|
|
17
|
+
}
|
|
18
|
+
/** llama.cpp b11669 (0.7.0, commit 620d2225c): self-contained, `@loader_path` dylibs, ad hoc signed. */
|
|
19
|
+
export declare const llamaRuntimePin: {
|
|
20
|
+
readonly name: "llama-b11669-bin-macos-arm64.tar.gz";
|
|
21
|
+
readonly url: "https://github.com/ggml-org/llama.cpp/releases/download/b11669/llama-b11669-bin-macos-arm64.tar.gz";
|
|
22
|
+
readonly bytes: 12191582;
|
|
23
|
+
readonly sha256: "dbed55493d72bc1ba2babc8770d669c2289b5bc4ee9fb2a4dfc158d45b7d13fd";
|
|
24
|
+
readonly directory: "llama-b11669";
|
|
25
|
+
readonly build: 11669;
|
|
26
|
+
readonly commit: "620d2225c";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Gemma 4 26B-A4B (Apache 2.0) at Unsloth's dynamic Q4_K_M is the default: on the 40-item Jungle eval it matched the
|
|
30
|
+
* 9B's idea coverage with no failures, a tenth of its copied details and about 60% of its time per item (see
|
|
31
|
+
* src/digest/README.md). Qwen3.5-9B is the pin for machines with 16-32 GiB.
|
|
32
|
+
*/
|
|
33
|
+
export declare const digestModels: readonly DigestModelPin[];
|
|
34
|
+
export declare const modelUrl: (m: Pick<DigestModelPin, "repo" | "revision" | "file">) => string;
|
|
35
|
+
/** An explicit alias, or the largest pin this machine's memory allows; `unsupported` below every pin. */
|
|
36
|
+
export declare function digestModel(alias?: string, memory?: number): DigestModelPin;
|
|
37
|
+
export declare function digestModelPackage(pin: DigestModelPin): ModelPackage;
|
|
38
|
+
/** Every pin's fingerprint, so runtime GC keeps any installed digest package. */
|
|
39
|
+
export declare const digestFingerprints: () => string[];
|
|
40
|
+
export interface DigestRuntime {
|
|
41
|
+
/** The verified llama.cpp release archive; extracted per run into owned scratch space. */
|
|
42
|
+
archive: string;
|
|
43
|
+
model: string;
|
|
44
|
+
pin: DigestModelPin;
|
|
45
|
+
}
|
|
46
|
+
export declare function digestSetupPlan(pin: DigestModelPin): {
|
|
47
|
+
id: string;
|
|
48
|
+
license: string;
|
|
49
|
+
fingerprint: string;
|
|
50
|
+
downloadBytes: number;
|
|
51
|
+
requiredFreeBytes: number;
|
|
52
|
+
artifacts: readonly import("./artifacts.js").ModelArtifact[];
|
|
53
|
+
model: string;
|
|
54
|
+
memoryBytes: number;
|
|
55
|
+
supported: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** Explicit setup: download (or adopt `modelFile`, a copy of the pinned GGUF the user already has) and verify. */
|
|
58
|
+
export declare function provisionDigest(cacheRoot: string, pin: DigestModelPin, options?: {
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
modelFile?: string;
|
|
61
|
+
fetch?: typeof fetch;
|
|
62
|
+
}): Promise<DigestRuntime>;
|
|
63
|
+
/** The installed runtime for `alias`, or for this machine's default pin, else the first installed pin. */
|
|
64
|
+
export declare function inspectDigest(cacheRoot: string, alias?: string): Promise<DigestRuntime>;
|