@particle-academy/prism-harness 0.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. package/package.json +34 -0
@@ -0,0 +1,513 @@
1
+ import { isJsonObject } from './json.js';
2
+ import { HarnessError } from './errors.js';
3
+ import { isDurable } from './stores/session-store.js';
4
+ import { DEFAULT_LEASE_SECONDS, isTerminalState, taskRecordFrom, } from './tasks.js';
5
+ /**
6
+ * A task whose state was read at a point in time.
7
+ *
8
+ * A SNAPSHOT, and it has to be: `AgentTask.state()` is synchronous, and the
9
+ * only honest way to make it live would be to hit the store, which cannot be
10
+ * done without returning a promise. Ask `StoreTaskSource.find()` for a fresh
11
+ * read; do not treat a task object held across an await as current.
12
+ */
13
+ export class StoredAgentTask {
14
+ #record;
15
+ constructor(record) {
16
+ this.#record = { ...record };
17
+ }
18
+ id() {
19
+ return this.#record.id;
20
+ }
21
+ instruction() {
22
+ return this.#record.instruction;
23
+ }
24
+ state() {
25
+ return this.#record.state;
26
+ }
27
+ claimedBy() {
28
+ return this.#record.claimed_by;
29
+ }
30
+ claimedUntil() {
31
+ return this.#record.claimed_until;
32
+ }
33
+ toRecord() {
34
+ return { ...this.#record };
35
+ }
36
+ }
37
+ /**
38
+ * The default task source: the list lives in the harness's own durable store.
39
+ *
40
+ * No schema, no migration, works on install. A consumer with an existing table
41
+ * adapts it instead (`asAgentTask` / `withAgentTask`); both satisfy one
42
+ * contract, and neither is the afterthought.
43
+ *
44
+ * ## Durable or nothing
45
+ *
46
+ * REFUSED AT CONSTRUCTION against a store that reports itself volatile. The
47
+ * package already draws this line for the durable slot and this inherits the
48
+ * rule rather than restating it: a half-finished task list that vanishes on a
49
+ * deploy is indistinguishable from a finished one, so losing it is a
50
+ * correctness failure and not a degradation to a default.
51
+ *
52
+ * ## The claim is one operation
53
+ *
54
+ * `claim()` does the whole read-expire-select-write inside ONE store lock.
55
+ * Reading the next task and then marking it taken is two operations with a
56
+ * window between them, and two workers arriving in that window both get the
57
+ * same task. That race is the reason this class exists, so the lock wraps
58
+ * everything rather than just the write.
59
+ *
60
+ * **The guarantee is the STORE'S, not this class's.** "Never the same task
61
+ * twice" holds exactly as far as `SessionStore.withLock` does:
62
+ * `MemorySessionStore`'s lock is process-local and `FileSessionStore`'s is one
63
+ * machine. Two workers on different machines over a network filesystem are not
64
+ * excluded by either, and no file lock can promise that -- point the durable
65
+ * slot at a database or Redis there. Saying so is the point: a claim that looks
66
+ * atomic and is not would hand the same expensive task to two agents.
67
+ *
68
+ * ## Size
69
+ *
70
+ * The whole list is one stored value, read and rewritten on every claim. That
71
+ * is right for the tens-of-tasks a bounded agent run produces and wrong for
72
+ * thousands: a consumer with a real backlog wants their own table and the
73
+ * application-model adapter, which is why that adapter is a first-class shape
74
+ * rather than an afterthought.
75
+ *
76
+ * ## Ordering
77
+ *
78
+ * Tasks are held in an array and `claim()` takes the first claimable one, so
79
+ * the order out is the order in. Nothing here sorts, prioritises or shuffles.
80
+ * Ordering is the divergence nothing reports: the agent just does the work in a
81
+ * different sequence and produces a different result.
82
+ */
83
+ export class StoreTaskSource {
84
+ #store;
85
+ #key;
86
+ #now;
87
+ constructor(store, key, options = {}) {
88
+ // Checked here, before anything can be written, because "refuse to start"
89
+ // is the requirement -- not "refuse once the first task is lost".
90
+ if (!isDurable(store.durability())) {
91
+ throw HarnessError.volatileTaskSource();
92
+ }
93
+ this.#store = store;
94
+ this.#key = key;
95
+ this.#now = options.now ?? (() => Math.floor(Date.now() / 1000));
96
+ }
97
+ /** What the underlying store says about itself. Always `durable` -- see the constructor. */
98
+ durability() {
99
+ return this.#store.durability();
100
+ }
101
+ /**
102
+ * The current time, FLOORED TO A WHOLE SECOND.
103
+ *
104
+ * Every timestamp this class writes is derived from here, so flooring once is
105
+ * what keeps `claimed_until` an integer. JavaScript has no int/float
106
+ * distinction, so a fractional clock produced `1090.75` and passed every
107
+ * equality assertion that had been written about it -- the bytes were
108
+ * `"claimed_until":1090.75` where the other two ports write `1090`, and only
109
+ * a `Number.isInteger` check on the stored value could see it. That test
110
+ * exists, and it found this.
111
+ */
112
+ #seconds() {
113
+ return Math.floor(this.#now());
114
+ }
115
+ /**
116
+ * Append a task, in `todo`.
117
+ *
118
+ * The id may be supplied -- a consumer seeding a list from their own domain
119
+ * keys wants their keys -- and is generated as `t-<n>` when it is not. A
120
+ * supplied id that is already in the list is REFUSED rather than merged: two
121
+ * units of work sharing an id would share a claim.
122
+ *
123
+ * An id of `""` is refused too, compared against the empty string EXACTLY.
124
+ * Nothing is trimmed first, in any of the three languages: `trim`, `strip`
125
+ * and `String.prototype.trim` each strip a different set of codepoints, so a
126
+ * trim here would give three different answers to the same input. A single
127
+ * space is a legal id, and that is the correct behaviour.
128
+ */
129
+ async add(instruction, id) {
130
+ if (id !== undefined && id === '') {
131
+ throw HarnessError.taskIdentifierBlank('task id');
132
+ }
133
+ return this.#store.withLock(this.#key, async () => {
134
+ const stored = await this.#read();
135
+ if (id !== undefined && stored.records.some((record) => record.id === id)) {
136
+ throw HarnessError.duplicateTaskId(id);
137
+ }
138
+ const taken = new Set(stored.records.map((record) => record.id));
139
+ let nextId = stored.nextId;
140
+ // A generated id must not land on one a caller supplied earlier.
141
+ while (id === undefined && taken.has(`t-${nextId}`))
142
+ nextId += 1;
143
+ const record = makeRecord(id ?? `t-${nextId}`, instruction, 'todo', null, null);
144
+ await this.#write([...stored.records, record], id === undefined ? nextId + 1 : stored.nextId);
145
+ return new StoredAgentTask(record);
146
+ });
147
+ }
148
+ /** Append several tasks at once, in the order given, with generated ids. */
149
+ async addMany(instructions) {
150
+ const added = [];
151
+ for (const instruction of instructions)
152
+ added.push(await this.add(instruction));
153
+ return added;
154
+ }
155
+ /**
156
+ * Take the next claimable task, atomically, or null when there is none.
157
+ *
158
+ * The claim is WRITTEN BEFORE this returns, which is the point: the caller
159
+ * cannot begin work until the store says the task is theirs, so a worker that
160
+ * dies mid-task leaves a `claimed` row with an expiry rather than a `todo`
161
+ * row that looks like it was never attempted.
162
+ */
163
+ async claim(worker, leaseSeconds = DEFAULT_LEASE_SECONDS) {
164
+ assertIdentifier(worker, 'worker id');
165
+ const lease = assertLease(leaseSeconds);
166
+ return this.#store.withLock(this.#key, async () => {
167
+ const now = this.#seconds();
168
+ const stored = await this.#read();
169
+ const { records, changed } = expireLeases(stored.records, now);
170
+ const index = records.findIndex((record) => record.state === 'todo');
171
+ if (index === -1) {
172
+ // Nothing to claim, but an expiry we noticed is a fact about the list
173
+ // and is persisted anyway. A reader must not see `claimed` for a lease
174
+ // that ran out just because the claim that noticed came up empty.
175
+ if (changed)
176
+ await this.#write(records, stored.nextId);
177
+ return null;
178
+ }
179
+ const previous = records[index];
180
+ const claimed = makeRecord(previous.id, previous.instruction, 'claimed', worker, now + lease);
181
+ records[index] = claimed;
182
+ await this.#write(records, stored.nextId);
183
+ return new StoredAgentTask(claimed);
184
+ });
185
+ }
186
+ /**
187
+ * Record what happened, as the worker HOLDING the task. Both outcomes are TERMINAL.
188
+ *
189
+ * Refuses a task that is already terminal rather than ignoring it: two
190
+ * callers believing they own one outcome is worth reporting, and the second
191
+ * write silently winning would leave nothing recording the disagreement.
192
+ *
193
+ * Refuses a task this worker is not holding -- nobody holding it, someone
194
+ * else holding it, or a lease that has since expired all reach the same
195
+ * refusal, because they have the same answer. The state machine has no edge
196
+ * from `todo` to `done`, and a lease that expired mid-flight has already put
197
+ * the task back where anyone can take it, so the worker that finally finished
198
+ * may no longer be the one whose result counts.
199
+ *
200
+ * And refuses an OUTCOME that is not one of the two, BEFORE anything else --
201
+ * see `assertOutcome`, and the ordering note there.
202
+ */
203
+ async release(task, worker, outcome) {
204
+ const recorded = assertOutcome(outcome);
205
+ assertIdentifier(worker, 'worker id');
206
+ await this.#store.withLock(this.#key, async () => {
207
+ const now = this.#seconds();
208
+ const stored = await this.#read();
209
+ const index = stored.records.findIndex((record) => record.id === task.id());
210
+ if (index === -1) {
211
+ throw HarnessError.taskNotFound(task.id());
212
+ }
213
+ const raw = stored.records[index];
214
+ const { records, changed } = expireLeases(stored.records, now);
215
+ const refusal = refuseUnheld(raw, worker, now);
216
+ if (refusal !== null) {
217
+ if (changed)
218
+ await this.#write(records, stored.nextId);
219
+ throw refusal;
220
+ }
221
+ // The owner and the expiry go to null. A terminal task is held by nobody,
222
+ // and leaving a stale holder on it would make `claimed_by` mean two
223
+ // different things depending on the state it sits beside.
224
+ records[index] = makeRecord(raw.id, raw.instruction, recorded, null, null);
225
+ await this.#write(records, stored.nextId);
226
+ });
227
+ }
228
+ /**
229
+ * How many tasks a `claim()` could still hand out.
230
+ *
231
+ * Counts `todo` AND any `claimed` task whose lease has run out, because an
232
+ * expired lease is claimable by anyone -- a loop that stopped while such a
233
+ * task sat there would stop with work left.
234
+ *
235
+ * Reads without taking the lock. A store `get` is a single atomic read of one
236
+ * key, so the count is consistent with some moment; taking the lock would
237
+ * make it consistent with a moment that has already passed by the time the
238
+ * caller reads it.
239
+ */
240
+ async pending() {
241
+ const now = this.#seconds();
242
+ const { records } = expireLeases((await this.#read()).records, now);
243
+ return records.filter((record) => record.state === 'todo').length;
244
+ }
245
+ /** A fresh read of one task, with any expired lease already applied, or null. */
246
+ async find(id) {
247
+ const now = this.#seconds();
248
+ const { records } = expireLeases((await this.#read()).records, now);
249
+ const record = records.find((candidate) => candidate.id === id);
250
+ return record === undefined ? null : new StoredAgentTask(record);
251
+ }
252
+ /**
253
+ * Push this worker's lease out, and ONLY while it still holds it.
254
+ *
255
+ * Bounded by the RUN, which is the whole argument. Unbounded self-extension
256
+ * is how a wedged worker holds a task forever, and a second timeout invented
257
+ * here would be one limit spelled two ways across an ecosystem -- which is
258
+ * how a limit ends up set in the place that is not enforced. The `RunBudget`
259
+ * is already the stop condition, so extension stops when the run's own
260
+ * allowance does. Nothing new to enforce and nothing new to forget.
261
+ *
262
+ * TWO checks, not one. `exhaustion()` refuses outright -- cancelled, out of
263
+ * steps, out of money or out of time -- and `remainingSeconds()` then bounds
264
+ * how much is granted. Reading the spec's "remaining wall-clock budget"
265
+ * literally and checking only the second left a cancelled run able to keep
266
+ * extending; the reference settled it the other way.
267
+ *
268
+ * Granted seconds are `min(requested, remaining)`. A budget with no
269
+ * wall-clock cap (`maxSeconds` null) grants what was asked, matching
270
+ * `RunLedger.exhaustion()`, which also has nothing to enforce in that case.
271
+ *
272
+ * The new expiry is `now + granted` EVEN WHEN THAT SHORTENS the lease. A
273
+ * first draft here took the later of the two, on the reasoning that a call
274
+ * named "extend" should never take time away. That is the wrong reasoning:
275
+ * the granted figure is what the RUN can still afford, so keeping a longer
276
+ * lease would hold a task past the budget that is supposed to bound it, and
277
+ * it would also make the result depend on the order calls happened to arrive
278
+ * in. One rule, one answer.
279
+ *
280
+ * @returns the new expiry, an integer Unix timestamp.
281
+ */
282
+ async extendLease(task, worker, seconds, ledger, budget) {
283
+ assertIdentifier(worker, 'worker id');
284
+ const requested = assertLease(seconds);
285
+ return this.#store.withLock(this.#key, async () => {
286
+ const now = this.#seconds();
287
+ const stored = await this.#read();
288
+ const index = stored.records.findIndex((record) => record.id === task.id());
289
+ if (index === -1) {
290
+ throw HarnessError.taskNotFound(task.id());
291
+ }
292
+ // Judged against the row AS STORED, not the expired-normalised copy, so
293
+ // the message can still say which of the three cases it was even though
294
+ // all three share one code.
295
+ const raw = stored.records[index];
296
+ const { records, changed } = expireLeases(stored.records, now);
297
+ const refusal = refuseUnheld(raw, worker, now);
298
+ if (refusal !== null) {
299
+ if (changed)
300
+ await this.#write(records, stored.nextId);
301
+ throw refusal;
302
+ }
303
+ // EXHAUSTION, not only the clock. The spec names the remaining
304
+ // wall-clock budget, and reading that literally was this port's first
305
+ // answer -- which left a CANCELLED run, or one that had spent its steps
306
+ // or its money, still able to push its lease out and hold a task it may
307
+ // no longer do anything with. `exhaustion()` is the existing stop
308
+ // condition and returns the REASON, so nothing new is invented and the
309
+ // four causes stay distinguishable.
310
+ const exhausted = ledger.exhaustion(budget);
311
+ if (exhausted !== null) {
312
+ if (changed)
313
+ await this.#write(records, stored.nextId);
314
+ throw HarnessError.runNotPermitted(`The lease held by [${worker}] on the task [${raw.id}] cannot be extended: ${exhausted}. ` +
315
+ 'The run is the bound on how long a worker may hold a task, and extending past it would ' +
316
+ 'be a second timeout wearing the first one\'s name.');
317
+ }
318
+ const remaining = ledger.remainingSeconds(budget);
319
+ const granted = remaining === null ? requested : Math.min(requested, remaining);
320
+ const until = now + granted;
321
+ records[index] = makeRecord(raw.id, raw.instruction, 'claimed', worker, until);
322
+ await this.#write(records, stored.nextId);
323
+ return until;
324
+ });
325
+ }
326
+ /**
327
+ * Read the list back, refusing anything it cannot map.
328
+ *
329
+ * A junk entry is an ERROR, not something to filter out. The thread does
330
+ * filter, and can afford to: a dropped message degrades a conversation. A
331
+ * dropped task is work that will never be done and nothing anywhere says so
332
+ * -- and a list that quietly shrinks is the failure mode this whole
333
+ * capability exists to prevent, arriving through the back door.
334
+ */
335
+ async #read() {
336
+ const stored = (await this.#store.get(this.#key)) ?? {};
337
+ const tasks = stored.tasks;
338
+ const records = Array.isArray(tasks)
339
+ ? tasks.map((entry) => {
340
+ if (!isJsonObject(entry)) {
341
+ throw HarnessError.unmappableContent(`a stored task list entry is [${typeof entry}] rather than an object`);
342
+ }
343
+ return taskRecordFrom(entry);
344
+ })
345
+ : [];
346
+ const nextId = typeof stored.next_id === 'number' ? Math.trunc(stored.next_id) : records.length + 1;
347
+ return { records, nextId };
348
+ }
349
+ async #write(records, nextId) {
350
+ const payload = {
351
+ tasks: records.map((record) => ({ ...record })),
352
+ next_id: nextId,
353
+ };
354
+ await this.#store.put(this.#key, payload);
355
+ }
356
+ }
357
+ /**
358
+ * The five canonical keys, always all of them, always in sorted order.
359
+ *
360
+ * Built through one function so no call site can forget a claim field and leave
361
+ * an `undefined` behind. `undefined` would survive every type check that only
362
+ * looks at the values it was given and then vanish at serialisation, taking the
363
+ * key with it.
364
+ */
365
+ function makeRecord(id, instruction, state, claimedBy, claimedUntil) {
366
+ return {
367
+ claimed_by: claimedBy,
368
+ claimed_until: claimedUntil,
369
+ id,
370
+ instruction,
371
+ state,
372
+ };
373
+ }
374
+ /**
375
+ * Return every task whose lease has run out to `todo`.
376
+ *
377
+ * TO `todo`, NEVER TO `failed`. A worker dying is not the task failing, and
378
+ * marking it failed burns a retry that never ran -- the application would then
379
+ * have to re-queue work that was never attempted, if it noticed at all.
380
+ *
381
+ * The expiry test is `claimed_until <= now`: the timestamp is the instant the
382
+ * lease ENDS, so at that instant it is over.
383
+ */
384
+ function expireLeases(records, now) {
385
+ let changed = false;
386
+ const next = records.map((record) => {
387
+ if (!leaseExpired(record, now))
388
+ return { ...record };
389
+ changed = true;
390
+ return makeRecord(record.id, record.instruction, 'todo', null, null);
391
+ });
392
+ return { records: next, changed };
393
+ }
394
+ function leaseExpired(record, now) {
395
+ return record.state === 'claimed' && record.claimed_until !== null && record.claimed_until <= now;
396
+ }
397
+ /**
398
+ * Why this worker may not act on this task -- or null when it may.
399
+ *
400
+ * TWO codes, not four. "Already finished" is its own, because the fix is to
401
+ * stop; the other three -- nobody holds it, someone else does, your lease ran
402
+ * out -- share `task_lease_not_held`, because the fix for all three is to claim
403
+ * it again. The sentence still distinguishes them for a human reading a log.
404
+ *
405
+ * The detail NEVER NAMES THE HOLDER. This refusal can surface inside a tool
406
+ * call, where a failure reaches the model as readable text, and an agent has no
407
+ * business learning the identity of a peer worker from an error message.
408
+ */
409
+ function refuseUnheld(record, worker, now) {
410
+ if (isTerminalState(record.state)) {
411
+ return HarnessError.taskAlreadyTerminal(record.id, record.state);
412
+ }
413
+ if (record.state !== 'claimed') {
414
+ return HarnessError.taskLeaseNotHeld(record.id, worker, 'nothing is holding it');
415
+ }
416
+ // Checked before expiry, deliberately. A lease that was never this worker's
417
+ // is not this worker's whether or not it has run out.
418
+ if (record.claimed_by !== worker) {
419
+ return HarnessError.taskLeaseNotHeld(record.id, worker, 'another worker is holding it');
420
+ }
421
+ if (leaseExpired(record, now)) {
422
+ return HarnessError.taskLeaseNotHeld(record.id, worker, 'its lease has expired');
423
+ }
424
+ return null;
425
+ }
426
+ /**
427
+ * Refuse the empty string, and NOTHING ELSE.
428
+ *
429
+ * Compared against `""` exactly, with no trimming. PHP's `trim`, JavaScript's
430
+ * `String.prototype.trim` and Python's `str.strip` each strip a different set
431
+ * of codepoints, so trimming here would mean three ports disagreeing about
432
+ * whether a given id is blank -- the shape of `prism-human-plus` G-36, where a
433
+ * single trailing space defeated a guard in all three languages at once. A
434
+ * worker id of one space is accepted on purpose.
435
+ */
436
+ function assertIdentifier(value, what) {
437
+ if (value === '') {
438
+ throw HarnessError.taskIdentifierBlank(what);
439
+ }
440
+ }
441
+ /**
442
+ * Exactly `done` or `failed`. Anything else is refused, never coerced.
443
+ *
444
+ * `TaskOutcome` is a union of string literals, which means it exists at COMPILE
445
+ * TIME AND NOWHERE ELSE. The annotation on `release()` stops nothing at run
446
+ * time: an outcome arriving from a queue payload, an HTTP body, a JSON config
447
+ * or any JavaScript caller reaches this method as an ordinary string, and the
448
+ * parameter is `unknown` here for the same reason `assertLease`'s is. Every
449
+ * door, not just the typed one.
450
+ *
451
+ * ## What made this a hole rather than a naming difference
452
+ *
453
+ * Found by `prism-parity/suites/agent-task-claim` (atc-0011, atc-0012), and it
454
+ * needed the cross-language corpus to see: the reference converts the string
455
+ * through a `TaskOutcome` ENUM at the call site and Python does the same, so
456
+ * both refuse before anything is written. Without this guard, `"complete"` was
457
+ * written into the DURABLE LIST as a `state` that is not one of the four, and
458
+ * nothing refused until a reader mapped the row back -- `unmappable_content`,
459
+ * for every language that opens that list. A TypeScript writer could poison a
460
+ * list a PHP or Python worker then could not read, and the language that
461
+ * reported the failure was not the one that caused it. See G-39.
462
+ *
463
+ * ## Refused FIRST, before the worker id and the holder check
464
+ *
465
+ * The order is observable and is chosen to match the reference, where the
466
+ * conversion happens in the argument expression and therefore before `release()`
467
+ * is entered at all. So a call that is wrong in two ways at once -- a blank
468
+ * worker AND an invalid outcome -- reports the outcome in all three languages.
469
+ * Nothing in the corpus pins this; it is pinned in this package's own suite.
470
+ */
471
+ function assertOutcome(outcome) {
472
+ if (outcome !== 'done' && outcome !== 'failed') {
473
+ throw HarnessError.taskOutcomeInvalid('StoreTaskSource.release()', outcome);
474
+ }
475
+ return outcome;
476
+ }
477
+ /**
478
+ * A lease of WHOLE POSITIVE SECONDS. Anything else is refused, never adjusted.
479
+ *
480
+ * Two rules that are really one rule, and this port had to be told the second.
481
+ *
482
+ * Zero or less was clamped to one second at first, because a clamp fails
483
+ * closed. That is not enough: a clamp is a value quietly becoming a DIFFERENT
484
+ * value, and this repository has already shipped a configuration that silently
485
+ * became a different configuration and stayed green throughout.
486
+ *
487
+ * A positive FRACTION was then still truncated -- `90.4` became `90` -- which is
488
+ * the same argument one scale down, and "truncation lands in the safe
489
+ * direction" is the clamping argument restated. It could never have been
490
+ * honoured as written either, since `claimed_until` is an integer timestamp in
491
+ * all three languages, so a fractional lease was always going to become a
492
+ * different lease.
493
+ *
494
+ * ## Every door, not just the typed one
495
+ *
496
+ * `Number.isInteger` is doing more work here than it looks, and the parameter is
497
+ * `unknown` on purpose. The type annotation on `claim()` stops nothing at
498
+ * runtime: a lease read out of a JSON config arrives as the STRING `'90.4'`,
499
+ * and `Math.trunc('90.4')` is `90` -- so the guard would have been defeated
500
+ * from inside the file that declares the setting, silently, exactly as it was
501
+ * in the reference. `Number.isInteger` is false for a float, for `NaN`, for
502
+ * either infinity, and for anything that is not a number at all, and it does
503
+ * not coerce.
504
+ *
505
+ * `NaN` deserves its own mention: `NaN <= 0` is FALSE, so a bare positivity
506
+ * check passes it straight through to become a `claimed_until` of `NaN`.
507
+ */
508
+ function assertLease(seconds) {
509
+ if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds <= 0) {
510
+ throw HarnessError.taskLeaseInvalid(seconds);
511
+ }
512
+ return seconds;
513
+ }
@@ -0,0 +1,34 @@
1
+ import type { JsonObject } from '../json.js';
2
+ import type { Durability, SessionStore } from './session-store.js';
3
+ /**
4
+ * State on disk, as one JSON file per key. DURABLE, and it says so.
5
+ *
6
+ * The default durable driver for a port with no database and no dependencies.
7
+ * A real deployment points the durable slot at a database instead; this exists
8
+ * so the package WORKS ON INSTALL rather than requiring infrastructure before
9
+ * the first session can be opened — the same reason the PHP reference defaults
10
+ * both slots to `database`.
11
+ *
12
+ * ## Two properties that matter more than speed
13
+ *
14
+ * **Writes are atomic.** A payload is written to a temporary file and renamed
15
+ * over the target, because `rename` is atomic on both POSIX and Windows. A
16
+ * partial write here is a corrupted thread, and a process killed mid-write is
17
+ * ordinary rather than exotic.
18
+ *
19
+ * **The lock is cross-process.** It is an exclusive-create lockfile (`wx`),
20
+ * which is the one primitive that is atomic on every filesystem worth
21
+ * supporting — unlike an "is it there?" check followed by a create, which has a
22
+ * window between the two. Two workers on one machine genuinely exclude each
23
+ * other. Two workers on different machines sharing a network filesystem do NOT
24
+ * reliably, and no file lock can promise that; use a database or Redis there.
25
+ */
26
+ export declare class FileSessionStore implements SessionStore {
27
+ #private;
28
+ constructor(directory: string);
29
+ durability(): Durability;
30
+ get(key: string): Promise<JsonObject | null>;
31
+ put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
32
+ forget(key: string): Promise<void>;
33
+ withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
34
+ }