@hraness/sponge 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +16 -7
  2. package/dist/migrations/011.d.ts +3 -0
  3. package/dist/migrations/011.js +9 -0
  4. package/dist/migrations/index.js +2 -0
  5. package/dist/src/annotations/quote.d.ts +1 -0
  6. package/dist/src/annotations/quote.js +11 -1
  7. package/dist/src/cli/commands.js +51 -6
  8. package/dist/src/cli/runtimes.js +6 -2
  9. package/dist/src/cli/worker.js +5 -1
  10. package/dist/src/digest/endpoint.d.ts +3 -2
  11. package/dist/src/digest/endpoint.js +3 -2
  12. package/dist/src/digest/pipeline.js +6 -3
  13. package/dist/src/digest/runner.d.ts +33 -0
  14. package/dist/src/digest/runner.js +82 -0
  15. package/dist/src/digest/schema.js +6 -2
  16. package/dist/src/digest/server.d.ts +40 -0
  17. package/dist/src/digest/server.js +166 -0
  18. package/dist/src/errors.d.ts +1 -0
  19. package/dist/src/errors.js +2 -0
  20. package/dist/src/importers/custody.d.ts +2 -5
  21. package/dist/src/importers/custody.js +3 -51
  22. package/dist/src/models/artifacts.d.ts +5 -0
  23. package/dist/src/models/artifacts.js +92 -17
  24. package/dist/src/models/digest.d.ts +64 -0
  25. package/dist/src/models/digest.js +122 -0
  26. package/dist/src/portability/database.js +2 -0
  27. package/dist/src/search/query.js +7 -54
  28. package/dist/src/store/digest-jobs.d.ts +55 -0
  29. package/dist/src/store/digest-jobs.js +164 -0
  30. package/dist/src/store/library.d.ts +3 -0
  31. package/dist/src/store/library.js +5 -1
  32. package/dist/src/store/queue.d.ts +9 -5
  33. package/dist/src/store/queue.js +14 -9
  34. package/dist/src/store/revisions.js +4 -0
  35. package/dist/src/worker/custody.d.ts +5 -0
  36. package/dist/src/worker/custody.js +7 -4
  37. package/dist/src/worker/transient.d.ts +17 -0
  38. package/dist/src/worker/transient.js +60 -0
  39. package/native/runtime-manifest.json +4 -4
  40. package/package.json +1 -1
@@ -0,0 +1,164 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { text } from './files.js';
3
+ import { checkOwner } from './lease.js';
4
+ import { now, transaction } from './context.js';
5
+ import { SpongeError } from '../errors.js';
6
+ /** Automatic retries a retryable failure gets before it stays failed. */
7
+ const maxAttempts = 3;
8
+ const alias = /^[\w.:/@+-]{1,256}$/;
9
+ /** Queue a digest of a newly captured revision when auto-digest is on. Runs inside the capture commit's transaction. */
10
+ export function enqueueDigest(db, itemId, revisionId) {
11
+ if (db.prepare('SELECT auto FROM digest_settings WHERE id=1').get()?.auto !== 1)
12
+ return;
13
+ db.prepare("INSERT INTO digest_jobs(id,item_id,revision_id,state,created_at) VALUES(?,?,?,'queued',?) ON CONFLICT(revision_id) DO NOTHING").run(randomUUID(), itemId, revisionId, now());
14
+ }
15
+ /**
16
+ * Digest jobs under the shared worker lease. A job names one revision; when its item moves on or is deleted before the
17
+ * worker reaches it, the job is cancelled as `superseded`, since the newer revision has a job of its own. The drafting
18
+ * server's process custody is the worker's filesystem journal (`withDigestServer`), so the database holds no attempt
19
+ * rows or staging, and owner recovery returns a crashed owner's running job to the queue.
20
+ */
21
+ export class DigestJobs {
22
+ c;
23
+ jobs;
24
+ constructor(c) {
25
+ this.c = c;
26
+ const db = c.db;
27
+ this.jobs = {
28
+ owns: (id) => !!db.prepare('SELECT 1 FROM digest_jobs WHERE id=?').get(id),
29
+ list: () => db
30
+ .prepare("SELECT d.*,'digest' AS operation FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE i.deleted_at IS NULL ORDER BY d.created_at,d.id")
31
+ .all(),
32
+ attempts: () => [],
33
+ cancel: (id) => transaction(db, () => {
34
+ const job = this.row(id);
35
+ if (['succeeded', 'cancelled'].includes(String(job.state)))
36
+ return;
37
+ db.prepare("UPDATE digest_jobs SET cancel_requested=1,state=CASE WHEN state='running' THEN state ELSE 'cancelled' END,finished_at=CASE WHEN state='running' THEN finished_at ELSE ? END WHERE id=?").run(now(), id);
38
+ }),
39
+ retry: (id, options) => {
40
+ if (options.automatic || options.notBefore !== undefined)
41
+ throw new SpongeError('invalid-input');
42
+ transaction(db, () => {
43
+ const job = this.row(id);
44
+ if (job.state !== 'failed' || job.cancel_requested)
45
+ throw new SpongeError('unavailable');
46
+ db.prepare("UPDATE digest_jobs SET state='queued',reason=NULL,retryable=0,not_before=NULL,attempt_count=0,finished_at=NULL WHERE id=?").run(id);
47
+ });
48
+ },
49
+ cancelRequested: (id) => this.row(id).cancel_requested === 1,
50
+ recover: () => {
51
+ throw new SpongeError('integrity');
52
+ },
53
+ release: (owner) => {
54
+ db.prepare("UPDATE digest_jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,owner_incarnation=NULL,fence=NULL WHERE state='running' AND owner_incarnation=? AND fence=?").run(owner.ownerIncarnation, owner.fence);
55
+ },
56
+ };
57
+ }
58
+ settings() {
59
+ const row = this.c.db.prepare('SELECT auto,model FROM digest_settings WHERE id=1').get();
60
+ return { auto: row.auto === 1, model: row.model === null ? null : String(row.model) };
61
+ }
62
+ /** Turn auto-digest on or off. Turning it on queues nothing already captured; turning it off leaves queued jobs. */
63
+ configure(settings) {
64
+ if (settings.auto !== undefined && typeof settings.auto !== 'boolean')
65
+ throw new SpongeError('invalid-input');
66
+ if (settings.model !== undefined && settings.model !== null && !alias.test(String(settings.model)))
67
+ throw new SpongeError('invalid-input');
68
+ transaction(this.c.db, () => {
69
+ if (settings.auto !== undefined)
70
+ this.c.db.prepare('UPDATE digest_settings SET auto=? WHERE id=1').run(Number(settings.auto));
71
+ if (settings.model !== undefined)
72
+ this.c.db.prepare('UPDATE digest_settings SET model=? WHERE id=1').run(settings.model);
73
+ });
74
+ return this.settings();
75
+ }
76
+ /** Whether a job is ready for `claim` now. */
77
+ due() {
78
+ return !!this.c.db
79
+ .prepare("SELECT 1 FROM digest_jobs WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?) LIMIT 1")
80
+ .get(now());
81
+ }
82
+ /**
83
+ * Delay every ready job to `notBefore` with `reason`, without counting an attempt: the drafting runtime is missing or
84
+ * could not start, so no job reached the model. Running jobs are untouched. Returns how many were delayed.
85
+ */
86
+ postpone(reason, notBefore) {
87
+ if (!/^[a-z][a-z-]{0,63}$/.test(reason) || !Number.isFinite(Date.parse(notBefore)))
88
+ throw new SpongeError('invalid-input');
89
+ return Number(this.c.db
90
+ .prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=? WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?)")
91
+ .run(reason, notBefore, now()).changes);
92
+ }
93
+ /** The oldest ready job whose revision is still its item's current one; jobs passed over on the way are superseded. */
94
+ claim(owner) {
95
+ const db = this.c.db;
96
+ return transaction(db, () => {
97
+ checkOwner(db, owner);
98
+ for (;;) {
99
+ const job = db
100
+ .prepare("SELECT d.id,d.item_id,d.revision_id,i.current_revision,i.deleted_at FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE d.state IN ('queued','delayed') AND d.cancel_requested=0 AND (d.not_before IS NULL OR d.not_before<=?) ORDER BY d.created_at,d.id LIMIT 1")
101
+ .get(now());
102
+ if (!job)
103
+ return null;
104
+ if (job.deleted_at !== null || job.current_revision !== job.revision_id) {
105
+ db.prepare("UPDATE digest_jobs SET state='cancelled',reason='superseded',finished_at=? WHERE id=?").run(now(), String(job.id));
106
+ continue;
107
+ }
108
+ db.prepare("UPDATE digest_jobs SET state='running',attempt_count=attempt_count+1,owner_incarnation=?,fence=? WHERE id=?").run(owner.ownerIncarnation, owner.fence, String(job.id));
109
+ return {
110
+ ...owner,
111
+ jobId: String(job.id),
112
+ itemId: String(job.item_id),
113
+ revisionId: String(job.revision_id),
114
+ };
115
+ }
116
+ });
117
+ }
118
+ /**
119
+ * Settle a claimed job. A retryable failure under the attempt limit is delayed until `notBefore` (default: a backoff
120
+ * from the attempt count); `countAttempt: false` gives the attempt back, for a refusal that never reached the model.
121
+ */
122
+ finish(claim, outcome) {
123
+ const db = this.c.db;
124
+ if ('reason' in outcome && !/^[a-z][a-z-]{0,63}$/.test(outcome.reason))
125
+ throw new SpongeError('invalid-input');
126
+ if ('reason' in outcome && outcome.notBefore !== undefined && !Number.isFinite(Date.parse(outcome.notBefore)))
127
+ throw new SpongeError('invalid-input');
128
+ transaction(db, () => {
129
+ checkOwner(db, claim);
130
+ const job = this.row(claim.jobId);
131
+ if (job.state !== 'running' || job.owner_incarnation !== claim.ownerIncarnation || job.fence !== claim.fence)
132
+ throw new SpongeError('owner-busy');
133
+ const release = 'UPDATE digest_jobs SET owner_incarnation=NULL,fence=NULL WHERE id=?';
134
+ if (job.cancel_requested) {
135
+ db.prepare("UPDATE digest_jobs SET state='cancelled',derivation_id=?,finished_at=? WHERE id=?").run('derivationId' in outcome ? outcome.derivationId : null, now(), claim.jobId);
136
+ }
137
+ else if ('derivationId' in outcome) {
138
+ text(outcome.derivationId, 256);
139
+ db.prepare("UPDATE digest_jobs SET state='succeeded',reason=NULL,retryable=0,not_before=NULL,derivation_id=?,finished_at=? WHERE id=?").run(outcome.derivationId, now(), claim.jobId);
140
+ }
141
+ else {
142
+ const attempts = Number(job.attempt_count) - (outcome.countAttempt === false ? 1 : 0);
143
+ if (outcome.retryable && (attempts < maxAttempts || outcome.countAttempt === false)) {
144
+ const due = outcome.notBefore ?? new Date(Date.now() + 60000 * 2 ** Math.max(0, attempts - 1)).toISOString();
145
+ db.prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=?,attempt_count=? WHERE id=?").run(outcome.reason, due, attempts, claim.jobId);
146
+ }
147
+ else
148
+ db.prepare("UPDATE digest_jobs SET state='failed',reason=?,retryable=?,not_before=NULL,attempt_count=?,finished_at=? WHERE id=?").run(outcome.reason, Number(!!outcome.retryable), attempts, now(), claim.jobId);
149
+ }
150
+ db.prepare(release).run(claim.jobId);
151
+ });
152
+ }
153
+ /** Whether the user asked to cancel the claimed job while it ran. */
154
+ cancelRequested(claim) {
155
+ return this.row(claim.jobId).cancel_requested === 1;
156
+ }
157
+ row(id) {
158
+ text(id, 256);
159
+ const job = this.c.db.prepare('SELECT * FROM digest_jobs WHERE id=?').get(id);
160
+ if (!job)
161
+ throw new SpongeError('unavailable');
162
+ return job;
163
+ }
164
+ }
@@ -6,6 +6,7 @@ import { Revisions } from './revisions.js';
6
6
  import { Derivations } from './derivations.js';
7
7
  import { Annotations } from './annotations.js';
8
8
  import { SearchIndex } from './search-index.js';
9
+ import { DigestJobs } from './digest-jobs.js';
9
10
  import { Backups } from './backups.js';
10
11
  import { Imports } from './imports.js';
11
12
  import type { ReadableItem, StoreOptions } from './types.js';
@@ -32,6 +33,8 @@ export declare class LibraryStore {
32
33
  readonly annotations: Annotations;
33
34
  /** Embedding index generations, index jobs, pruning and hybrid search. */
34
35
  readonly index: SearchIndex;
36
+ /** Digest jobs and the auto-digest settings. */
37
+ readonly digests: DigestJobs;
35
38
  readonly backups: Backups;
36
39
  readonly imports: Imports;
37
40
  constructor(root: string, db: DatabaseSync, options: StoreOptions);
@@ -11,6 +11,7 @@ import { Revisions } from './revisions.js';
11
11
  import { Derivations } from './derivations.js';
12
12
  import { Annotations } from './annotations.js';
13
13
  import { SearchIndex } from './search-index.js';
14
+ import { DigestJobs } from './digest-jobs.js';
14
15
  import { Backups } from './backups.js';
15
16
  import { Imports } from './imports.js';
16
17
  import { validateMetadataWarnings } from '../../contracts/validate.js';
@@ -32,6 +33,8 @@ export class LibraryStore {
32
33
  annotations;
33
34
  /** Embedding index generations, index jobs, pruning and hybrid search. */
34
35
  index;
36
+ /** Digest jobs and the auto-digest settings. */
37
+ digests;
35
38
  backups;
36
39
  imports;
37
40
  constructor(root, db, options) {
@@ -39,7 +42,8 @@ export class LibraryStore {
39
42
  const c = { root, db, options };
40
43
  this.db = db;
41
44
  this.index = new SearchIndex(c);
42
- this.queue = new Queue(c, this.index.jobs);
45
+ this.digests = new DigestJobs(c);
46
+ this.queue = new Queue(c, [this.index.jobs, this.digests.jobs]);
43
47
  this.blobs = new Blobs(c);
44
48
  this.revisions = new Revisions(c);
45
49
  this.derivations = new Derivations(c);
@@ -6,7 +6,8 @@ export interface RetryOptions {
6
6
  automatic?: boolean;
7
7
  }
8
8
  /**
9
- * One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`. The queue dispatches
9
+ * One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`, digest jobs in
10
+ * `digest-jobs.ts`. The queue dispatches
10
11
  * listing, cancellation, retry and owner recovery through this interface, so a new job kind plugs in without new branches.
11
12
  */
12
13
  export interface JobKind {
@@ -19,19 +20,22 @@ export interface JobKind {
19
20
  cancelRequested(jobId: string): boolean;
20
21
  /** Inside `recoverOwner`'s transaction, once custody of the attempt is proved: remove its staging and settle it. */
21
22
  recover(attempt: OwnedAttempt): void;
23
+ /** Inside `recoverOwner`'s transaction: return work the recovered owner held without attempt rows. */
24
+ release?(owner: OwnerToken): void;
22
25
  }
23
26
  /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
24
27
  export declare class Queue {
25
28
  private readonly c;
26
- private readonly index;
29
+ private readonly kinds;
27
30
  private readonly capture;
28
- constructor(c: Context, index: JobKind);
29
- /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
31
+ /** `kinds` are every other job kind, in dispatch order. */
32
+ constructor(c: Context, kinds: readonly JobKind[]);
33
+ /** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
30
34
  private kindOf;
31
35
  acquireOwner(ownerIncarnation: string): OwnerToken;
32
36
  releaseOwner(token: OwnerToken): void;
33
37
  ownerSnapshot(): OwnerToken | null;
34
- /** Every attempt the owner claimed, capture first, then index. */
38
+ /** Every attempt the owner claimed, capture first, then the other kinds in order. */
35
39
  ownerAttempts(token: OwnerToken): OwnedAttempt[];
36
40
  /** Requires external custody proof for EVERY pending attempt, even after its job committed.
37
41
  * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
@@ -12,11 +12,12 @@ const activeStates = "('queued','running','delayed','cleanup-pending')";
12
12
  /** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
13
13
  export class Queue {
14
14
  c;
15
- index;
15
+ kinds;
16
16
  capture;
17
- constructor(c, index) {
17
+ /** `kinds` are every other job kind, in dispatch order. */
18
+ constructor(c, kinds) {
18
19
  this.c = c;
19
- this.index = index;
20
+ this.kinds = kinds;
20
21
  const db = c.db;
21
22
  this.capture = {
22
23
  owns: (id) => !!db.prepare('SELECT 1 FROM jobs WHERE id=?').get(id),
@@ -99,9 +100,9 @@ export class Queue {
99
100
  },
100
101
  };
101
102
  }
102
- /** Index jobs are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
103
+ /** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
103
104
  kindOf(jobId) {
104
- return this.index.owns(jobId) ? this.index : this.capture;
105
+ return this.kinds.find((kind) => kind.owns(jobId)) ?? this.capture;
105
106
  }
106
107
  acquireOwner(ownerIncarnation) {
107
108
  text(ownerIncarnation, 256);
@@ -127,10 +128,10 @@ export class Queue {
127
128
  ownerSnapshot() {
128
129
  return ownerSnapshot(this.c.db);
129
130
  }
130
- /** Every attempt the owner claimed, capture first, then index. */
131
+ /** Every attempt the owner claimed, capture first, then the other kinds in order. */
131
132
  ownerAttempts(token) {
132
133
  checkOwner(this.c.db, token);
133
- return [...this.capture.attempts(token), ...this.index.attempts(token)];
134
+ return [this.capture, ...this.kinds].flatMap((kind) => kind.attempts(token));
134
135
  }
135
136
  /** Requires external custody proof for EVERY pending attempt, even after its job committed.
136
137
  * IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
@@ -146,7 +147,9 @@ export class Queue {
146
147
  if (proof.size !== pending.length || pending.some((a) => !proof.has(a.attemptId)))
147
148
  throw new SpongeError('owner-busy');
148
149
  for (const attempt of pending)
149
- (attempt.kind === 'index' ? this.index : this.capture).recover(attempt);
150
+ this.kindOf(attempt.jobId).recover(attempt);
151
+ for (const kind of this.kinds)
152
+ kind.release?.(expected);
150
153
  this.c.db.prepare('UPDATE worker_owner SET incarnation=NULL,fence=fence+1 WHERE id=1').run();
151
154
  });
152
155
  }
@@ -217,7 +220,9 @@ export class Queue {
217
220
  }
218
221
  /** Jobs of every kind, oldest first. */
219
222
  list() {
220
- return [...this.capture.list(), ...this.index.list()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
223
+ return [this.capture, ...this.kinds]
224
+ .flatMap((kind) => kind.list())
225
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
221
226
  }
222
227
  /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
223
228
  source(jobId) {
@@ -11,6 +11,7 @@ import { indexReadable } from '../search/lexical.js';
11
11
  import { trackLibrary } from '../indexing/index.js';
12
12
  import { checkAttempt } from './lease.js';
13
13
  import { importReceipt } from './imports.js';
14
+ import { enqueueDigest } from './digest-jobs.js';
14
15
  import { bumpEpoch, now, transaction } from './context.js';
15
16
  import { SpongeError } from '../errors.js';
16
17
  /** Captured revisions: content-addressed, immutable once committed, and the item's readable pointer. */
@@ -151,6 +152,9 @@ export class Revisions {
151
152
  bumpEpoch(db);
152
153
  // Active indexes follow the new revision; only its new chunks are queued for embedding.
153
154
  trackLibrary(db);
155
+ // A capture, not an import: imported libraries are digested on purpose, not as a side effect.
156
+ if (!imported)
157
+ enqueueDigest(db, input.itemId, revisionId);
154
158
  }
155
159
  if (imported) {
156
160
  const receipt = importReceipt(db, imported.namespace, imported.sourceKey, imported.sourceHash);
@@ -7,6 +7,10 @@ export interface WorkerCustody {
7
7
  export declare function acquireWorkerCustody(root: string): Promise<WorkerCustody>;
8
8
  /** Receipts are specific to every pre-launch ticket. Missing receipts never mean absent children. */
9
9
  export declare function proveAttemptCleanup(ownerDirectory: string, incarnation: string, attemptId: string): Promise<boolean>;
10
+ /** Attempts outside the job tables: a search query, an import verification, a digest server. Each writes `KIND.json`
11
+ * under its attempt and `staging/KIND-ID/ownership.json`. */
12
+ declare const transientKinds: readonly ["query", "import-verification", "digest-server"];
13
+ export type TransientKind = (typeof transientKinds)[number];
10
14
  export type RecoveryFault = 'guarded' | 'recovered';
11
15
  export declare function recoverWorker(store: LibraryStore, options?: {
12
16
  fault?: (point: RecoveryFault) => void;
@@ -14,3 +18,4 @@ export declare function recoverWorker(store: LibraryStore, options?: {
14
18
  recovered: boolean;
15
19
  attempts: number;
16
20
  }>;
21
+ export {};
@@ -116,6 +116,9 @@ async function sweepTemps(path, depth = 2) {
116
116
  if (removed)
117
117
  await syncDirectoryAsync(path);
118
118
  }
119
+ /** Attempts outside the job tables: a search query, an import verification, a digest server. Each writes `KIND.json`
120
+ * under its attempt and `staging/KIND-ID/ownership.json`. */
121
+ const transientKinds = ['query', 'import-verification', 'digest-server'];
119
122
  export async function recoverWorker(store, options = {}) {
120
123
  const lock = join(store.root, 'runtime', 'worker.lock');
121
124
  let record;
@@ -171,7 +174,7 @@ export async function recoverWorker(store, options = {}) {
171
174
  identifier(id);
172
175
  if (!attemptIds.has(id)) {
173
176
  let marker;
174
- for (const kind of ['query', 'import-verification']) {
177
+ for (const kind of transientKinds) {
175
178
  try {
176
179
  const candidate = await readJournal(join(ownerDirectory, 'attempts', id, kind + '.json'));
177
180
  if (marker || candidate.kind !== kind)
@@ -189,7 +192,7 @@ export async function recoverWorker(store, options = {}) {
189
192
  }
190
193
  else {
191
194
  if (marker.schemaVersion !== 1 ||
192
- !['query', 'import-verification'].includes(marker.kind) ||
195
+ !transientKinds.includes(marker.kind) ||
193
196
  marker.attemptId !== id ||
194
197
  marker.ownerIncarnation !== record.incarnation)
195
198
  throw pending();
@@ -201,7 +204,7 @@ export async function recoverWorker(store, options = {}) {
201
204
  }
202
205
  const staging = join(store.root, 'staging');
203
206
  for (const entry of await readdir(staging)) {
204
- if (entry.startsWith('query-') || entry.startsWith('import-verification-')) {
207
+ if (transientKinds.some((kind) => entry.startsWith(kind + '-'))) {
205
208
  const path = join(staging, entry);
206
209
  await directory(path);
207
210
  let marker;
@@ -212,7 +215,7 @@ export async function recoverWorker(store, options = {}) {
212
215
  throw pending();
213
216
  }
214
217
  if (marker.schemaVersion !== 1 ||
215
- !['query', 'import-verification'].includes(marker.kind) ||
218
+ !transientKinds.includes(marker.kind) ||
216
219
  typeof marker.requestId !== 'string' ||
217
220
  entry !== marker.kind + '-' + marker.requestId ||
218
221
  typeof marker.ownerIncarnation !== 'string')
@@ -0,0 +1,17 @@
1
+ import { type TransientKind } from './custody.js';
2
+ import { type ProcessRunner } from './supervisor.js';
3
+ export interface TransientAttempt {
4
+ attemptId: string;
5
+ /** The custody incarnation, for `queue.acquireOwner` when the attempt also claims jobs. */
6
+ incarnation: string;
7
+ ownerDirectory: string;
8
+ scratchRoot: string;
9
+ /** Runs helpers under the worker supervisor, so their process groups are proved collected. */
10
+ runProcess: ProcessRunner;
11
+ }
12
+ /**
13
+ * One custody-held attempt outside the job tables. It writes `KIND.json` under the attempt and an ownership marker in
14
+ * `staging/KIND-ID` (what `recoverWorker` expects), proves every helper collected, removes the scratch root and
15
+ * releases custody. Unproven cleanup keeps custody and throws `cleanup-required`. Mutates coordination journals only.
16
+ */
17
+ export declare function withTransientAttempt<T>(libraryRoot: string, kind: TransientKind, fn: (attempt: TransientAttempt) => Promise<T>): Promise<T>;
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { rm } from 'node:fs/promises';
4
+ import { acquireWorkerCustody, proveAttemptCleanup } from './custody.js';
5
+ import { directory, writeJournal, readJournal } from './journal.js';
6
+ import { supervisedRunner } from './supervisor.js';
7
+ import { syncDirectoryAsync } from '../util/fsync.js';
8
+ import { SpongeError, codeOf } from '../errors.js';
9
+ /**
10
+ * One custody-held attempt outside the job tables. It writes `KIND.json` under the attempt and an ownership marker in
11
+ * `staging/KIND-ID` (what `recoverWorker` expects), proves every helper collected, removes the scratch root and
12
+ * releases custody. Unproven cleanup keeps custody and throws `cleanup-required`. Mutates coordination journals only.
13
+ */
14
+ export async function withTransientAttempt(libraryRoot, kind, fn) {
15
+ const custody = await acquireWorkerCustody(libraryRoot), attemptId = randomUUID();
16
+ let scratchRoot, cleanupUnknown = false;
17
+ try {
18
+ const attempts = await directory(join(custody.directory, 'attempts', attemptId));
19
+ await writeJournal(join(attempts, kind + '.json'), {
20
+ schemaVersion: 1,
21
+ kind,
22
+ attemptId,
23
+ ownerIncarnation: custody.incarnation,
24
+ });
25
+ scratchRoot = await directory(join(libraryRoot, 'staging', `${kind}-${attemptId}`));
26
+ await writeJournal(join(scratchRoot, 'ownership.json'), {
27
+ schemaVersion: 1,
28
+ kind,
29
+ requestId: attemptId,
30
+ ownerIncarnation: custody.incarnation,
31
+ });
32
+ return await fn({
33
+ attemptId,
34
+ incarnation: custody.incarnation,
35
+ ownerDirectory: custody.directory,
36
+ scratchRoot,
37
+ runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId),
38
+ });
39
+ }
40
+ catch (error) {
41
+ cleanupUnknown = codeOf(error) === 'cleanup-required';
42
+ throw error;
43
+ }
44
+ finally {
45
+ const proved = !cleanupUnknown && (await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId));
46
+ if (!proved)
47
+ throw new SpongeError('cleanup-required');
48
+ if (scratchRoot) {
49
+ const marker = await readJournal(join(scratchRoot, 'ownership.json'));
50
+ if (marker.schemaVersion !== 1 ||
51
+ marker.kind !== kind ||
52
+ marker.requestId !== attemptId ||
53
+ marker.ownerIncarnation !== custody.incarnation)
54
+ throw new SpongeError('cleanup-required');
55
+ await rm(scratchRoot, { recursive: true });
56
+ await syncDirectoryAsync(join(libraryRoot, 'staging'));
57
+ }
58
+ await custody.release();
59
+ }
60
+ }
@@ -27,14 +27,14 @@
27
27
  }
28
28
  ],
29
29
  "build": {
30
- "asrSourceSha256": "ca98fb111b0d2b26ca697a6c0d55d76f7d2b5b752bff8fc053ae19389d16a52c",
30
+ "asrSourceSha256": "21a284b38eca324015bc9d03efb14335a5a73f8b3761b0f547180bf851fce2a3",
31
31
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
32
- "embeddingSourceSha256": "af6d6c01934d64427f2a9cb36f83edaea372f6bf2e522166b9e86e3b15f72784",
32
+ "embeddingSourceSha256": "ff0a27cefa0ddc23e1d8bea7a70a49a112865ee730500a1da69ca9b8e6104adc",
33
33
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
34
34
  },
35
35
  "minimumMacOS": "26.0",
36
- "packageVersion": "0.3.4",
36
+ "packageVersion": "0.4.0",
37
37
  "platform": "darwin",
38
38
  "schemaVersion": 1,
39
- "sourceCommit": "4e42216e86da07224ec81512c2efa0a710e38500"
39
+ "sourceCommit": "307210823c8523059f896fb64ff92dade3760b59"
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",