@nathapp/nax-agent 0.91.7 → 0.91.8-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/internal/atomic-write.d.ts +22 -7
  5. package/dist/internal/atomic-write.js +107 -12
  6. package/dist/internal/pinned-directory-worker.d.ts +52 -0
  7. package/dist/internal/pinned-directory-worker.js +105 -0
  8. package/dist/internal/pinned-directory.d.ts +48 -0
  9. package/dist/internal/pinned-directory.js +168 -0
  10. package/dist/internal.d.ts +1 -0
  11. package/dist/internal.js +1 -0
  12. package/dist/memory/delivery.d.ts +38 -0
  13. package/dist/memory/delivery.js +28 -0
  14. package/dist/memory/index-render.d.ts +51 -0
  15. package/dist/memory/index-render.js +210 -0
  16. package/dist/memory/index.d.ts +12 -0
  17. package/dist/memory/index.js +11 -0
  18. package/dist/memory/local-provider.d.ts +3 -0
  19. package/dist/memory/local-provider.js +80 -0
  20. package/dist/memory/mirror-lock.d.ts +109 -0
  21. package/dist/memory/mirror-lock.js +334 -0
  22. package/dist/memory/note.d.ts +15 -0
  23. package/dist/memory/note.js +131 -0
  24. package/dist/memory/operations.d.ts +120 -0
  25. package/dist/memory/operations.js +440 -0
  26. package/dist/memory/outbox.d.ts +35 -0
  27. package/dist/memory/outbox.js +289 -0
  28. package/dist/memory/provider-types.d.ts +50 -0
  29. package/dist/memory/provider-types.js +1 -0
  30. package/dist/memory/records.d.ts +29 -0
  31. package/dist/memory/records.js +50 -0
  32. package/dist/memory/refusal.d.ts +36 -0
  33. package/dist/memory/refusal.js +25 -0
  34. package/dist/memory/search.d.ts +17 -0
  35. package/dist/memory/search.js +112 -0
  36. package/dist/memory/store.d.ts +102 -0
  37. package/dist/memory/store.js +455 -0
  38. package/dist/memory/support.d.ts +63 -0
  39. package/dist/memory/support.js +98 -0
  40. package/dist/memory/sync-flight.d.ts +25 -0
  41. package/dist/memory/sync-flight.js +65 -0
  42. package/dist/memory/sync.d.ts +82 -0
  43. package/dist/memory/sync.js +344 -0
  44. package/dist/memory/types.d.ts +96 -0
  45. package/dist/memory/types.js +51 -0
  46. package/dist/memory/validate.d.ts +31 -0
  47. package/dist/memory/validate.js +104 -0
  48. package/dist/session-store/index.d.ts +1 -1
  49. package/dist/session-store/index.js +1 -1
  50. package/package.json +3 -3
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The memory operations: what an agent's intent becomes once the store agrees to
3
+ * keep it.
4
+ *
5
+ * `saveMemoryNote` is the write path, and the only place a `MemoryNoteWrite`
6
+ * becomes a `MemoryNote`. Validation comes first and everything else after it:
7
+ * `validateMemoryNoteWrite` owns what may be stored (`validate.ts` — the field
8
+ * limits and the secret scan), so a refusal is thrown as `MEMORY_WRITE_REFUSED`
9
+ * with that reason as its message and nothing on disk is touched. This file adds
10
+ * no rule of its own; it only decides what to do with the answer.
11
+ *
12
+ * A write that passes is staged in the mirror before any provider is asked, and the
13
+ * staging is what outlives a provider that cannot take it: a provider that does not
14
+ * answer, and a push that fails or times out, leave the note exactly where it is and
15
+ * put the request — under the id the note was staged with — on the outbox, so a later
16
+ * sync delivers what this session already remembers. Only a permanent refusal is
17
+ * undone: the note under the key is read before staging (`previous`) and written back
18
+ * — or removed, when there was none — because a refusal is the one answer that will
19
+ * never change. After acceptance there is no rollback either: the provider holds the
20
+ * note, so a mirror write that then fails leaves the staged note in place and reports
21
+ * the bookkeeping failure. See `refusal.ts` for what a refusal does to the queue and
22
+ * the mirror, and why each step stands on its own.
23
+ *
24
+ * The note is minted with its own UUID — which is also the request's client id, so the save
25
+ * and the write that delivers it are named by one id — starts `active` under `direct` and
26
+ * `pending` under `proposal`, and is stamped with the caller's clock. The provider is probed
27
+ * and pushed through their deadlines as `raceTimeout` races, and its answer, never the
28
+ * caller's guess, decides the note's final id and status.
29
+ *
30
+ * A key the outbox already holds a queued write for is not pushed at all: the new one joins
31
+ * the queue behind it, so the provider receives them in the order the agent made them. That
32
+ * order is only meaningful if two saves of the key cannot both decide it, so a mirror's saves
33
+ * run one after another — in this process (`orderedByMirrorLocks`) and across processes (the
34
+ * store's lock files, project and user alike). A contending save waits for the holder to
35
+ * release (`waitForTurn`) before it stages or queues, so a holder paused before enqueueing
36
+ * still keeps the earlier sequence; an entry for the key alone is not permission to proceed,
37
+ * because it may predate the current holder. Only a save fenced by a sync known to this
38
+ * process queues without staging — it leaves the mirror and index to that sync — and every
39
+ * save that stages refreshes the index, so the next session reads the note whether or not this
40
+ * session could deliver it.
41
+ *
42
+ * `forgetMemoryNote` is the other half, and it only retires a note the mirror holds: an absent
43
+ * slug is a refusal. It declares its queue entry *before* touching the note, so the intent is
44
+ * durable first; under `direct` the note then goes, kept aside for a refusal to put back, and
45
+ * under `proposal` it stays, with the marker coming from the outbox alone.
46
+ *
47
+ * `approveMemoryNote` is the transition a proposal waits for: a pending note is rewritten in force
48
+ * under the caller's clock and the index is regenerated from the mirror, a note already in force is
49
+ * returned as it stands, and a note that is history — or one this mirror does not hold — is the
50
+ * same `MEMORY_NOTE_NOT_FOUND` refusal a forget of it would be. It asks no provider and writes
51
+ * nothing to the queue, but it still writes the mirror, so it takes the same ordering and the same
52
+ * locks a save takes. It reads the held-forget set only to keep the index's marker, and approves
53
+ * no queued forget: the entry stays the outbox's.
54
+ */
55
+ import { randomUUID } from "node:crypto";
56
+ import { getLogger } from "#src/infra/agent-logger";
57
+ import { NaxError } from "#src/infra/nax-error";
58
+ import { refreshMemoryIndex } from "./index-render.js";
59
+ import { clockOf, mirrorSyncInFlight, orderedByMirrorLocks, withMirrorLock, } from "./mirror-lock.js";
60
+ import { isMemoryPushRejected } from "./note.js";
61
+ import { createMemoryOutbox } from "./outbox.js";
62
+ import { discardRefused, restoreForgotten, restoreNote } from "./refusal.js";
63
+ import { GLOBAL_MEMORY_TIMERS, providerAnswers, raceTimeout } from "./support.js";
64
+ import { MEMORY_OP_TIMEOUT_MS, memoryNoteKey, } from "./types.js";
65
+ import { forgetReasonRefusal, validateMemoryNoteWrite } from "./validate.js";
66
+ const ERROR_STAGE = "memory";
67
+ /** The status a write starts in: a proposal is not in force until it is approved. */
68
+ const STATUS_BY_MODE = {
69
+ direct: "active",
70
+ proposal: "pending",
71
+ };
72
+ /** The status a forget rests in with no provider answer: retired, or still awaiting approval. */
73
+ const FORGET_STATUS_BY_MODE = {
74
+ direct: "deleted",
75
+ proposal: "pending",
76
+ };
77
+ /** The refusal a rejected write is thrown as: the validator's reason as the message, its field as context. */
78
+ function writeRefused(refusal) {
79
+ return new NaxError(refusal.reason, "MEMORY_WRITE_REFUSED", { stage: ERROR_STAGE, field: refusal.field });
80
+ }
81
+ /** The note a write becomes: a fresh id, the mode's starting status and the caller's clock. */
82
+ function stagedNote(input) {
83
+ const { write } = input;
84
+ return {
85
+ id: randomUUID(),
86
+ slug: write.slug,
87
+ type: write.type,
88
+ scope: write.scope,
89
+ description: write.description,
90
+ source: input.source,
91
+ updatedAt: clockOf(input)().toISOString(),
92
+ status: STATUS_BY_MODE[input.writeMode],
93
+ body: write.body,
94
+ };
95
+ }
96
+ /** The request one staged note is pushed as: the note's own id keys the write. */
97
+ function saveRequest(note, writeMode) {
98
+ return { kind: "save", clientId: note.id, scope: note.scope, slug: note.slug, writeMode, note };
99
+ }
100
+ /**
101
+ * The post-acceptance half: record what the provider made of the note.
102
+ *
103
+ * Only the provider's id and status are taken from the answer — the note's own
104
+ * source and timestamp are the caller's, and the delivery says what became of the
105
+ * write, not when it was made. This runs after the provider has accepted the
106
+ * write, so a failure here leaves the staged note in place: the mirror keeps the
107
+ * note the provider holds, and the bookkeeping failure reaches the caller. Rolling
108
+ * back instead would hand the mirror a note the provider no longer agrees with.
109
+ */
110
+ async function recordDelivered(input, note, pushed) {
111
+ await input.store.writeNote({ ...note, id: pushed.id, status: pushed.status });
112
+ }
113
+ /**
114
+ * Regenerate the indexes from what the store now holds, so the next session reads this write.
115
+ *
116
+ * The queue is read for the notes whose forget is still waiting: that marker is the outbox
117
+ * speaking, and an index rendered without it would report a retire that is still in flight as
118
+ * though it had never been asked for.
119
+ */
120
+ async function refreshIndex(input) {
121
+ await refreshMemoryIndex(input.store, { heldForgets: await input.outbox.heldKeys("forget") });
122
+ }
123
+ /** The undelivered answer: the write is on the queue, not delivered. */
124
+ function queued(note) {
125
+ return { slug: note.slug, status: note.status, delivered: false };
126
+ }
127
+ /**
128
+ * Queue a write this save could not deliver and rebuild the index from what the mirror holds:
129
+ * the staged note is what the mirror keeps, so the next session has to see it — whether this
130
+ * save staged it or it was already there when this one joined the queue behind another.
131
+ */
132
+ async function queueUnderLock(input, note, request) {
133
+ await input.outbox.enqueue(request);
134
+ await refreshIndex(input);
135
+ return queued(note);
136
+ }
137
+ /**
138
+ * The answer for a write that is already on the queue and whose staged note the mirror
139
+ * keeps: the entry is what makes it durable, so the index is the only thing left to rebuild.
140
+ */
141
+ async function queuedEntryResult(input, note) {
142
+ await refreshIndex(input);
143
+ return queued(note);
144
+ }
145
+ /**
146
+ * Queue a write another writer's mirror could not take: the entry is the only thing this
147
+ * save writes.
148
+ *
149
+ * The index is not rebuilt here on purpose: rendering it from a mirror its owner is still
150
+ * changing could publish their half-written state over the view this save refused to touch,
151
+ * and the owner's own refresh (or the sync that delivers this entry) is the one that counts.
152
+ * The request keeps the id the note was minted with, so the note, the entry and the write a
153
+ * later sync sends share one client id and a replay is recognisable.
154
+ */
155
+ async function queueFenced(input, note, request) {
156
+ await input.outbox.enqueue(request);
157
+ return queued(note);
158
+ }
159
+ /**
160
+ * Stage the note in the mirror and declare the write on the queue, while holding the lock.
161
+ *
162
+ * The note under the key is read first, because a permanent refusal has to be able to put it
163
+ * back. A key the outbox already holds a queued write for is not taken any further: this
164
+ * write joins the queue behind the one already waiting, and the order the agent wrote them
165
+ * in survives. Otherwise the note is staged and its request goes on the queue *before* the
166
+ * provider is asked. The lock covers both declaration and delivery, so a contending
167
+ * save cannot take an earlier sequence even if this holder is paused before enqueueing.
168
+ * A delivery that succeeds takes the entry back off again.
169
+ */
170
+ async function deliverLocked(input) {
171
+ const note = stagedNote(input);
172
+ const request = saveRequest(note, input.writeMode);
173
+ const previous = await input.store.readNote(input.write.scope, input.write.slug);
174
+ await input.store.writeNote(note);
175
+ if ((await input.outbox.heldKeys()).has(memoryNoteKey(note.scope, note.slug))) {
176
+ return await queueUnderLock(input, note, request);
177
+ }
178
+ await input.outbox.enqueue(request);
179
+ return await handOver(input, note, previous);
180
+ }
181
+ /**
182
+ * Ask the provider for the note the queue already carries, and take the entry back on success.
183
+ *
184
+ * Anything short of an acceptance leaves the entry where it is and reports the save as
185
+ * undelivered; the staged note stays too, because the local write is the newer fact and is
186
+ * exactly what the queue will deliver.
187
+ *
188
+ * `MEMORY_PUSH_REJECTED` is the provider saying it will never take this write, so queueing it
189
+ * would only repeat the refusal: the entry comes back off, the mirror is put back the way it
190
+ * was, and the provider's error reaches the caller. Only the provider's answer changes the
191
+ * note's id and status, and only after it has taken the write — the delivered arm is the one
192
+ * arm that cannot be undone.
193
+ */
194
+ async function handOver(input, note, previous) {
195
+ const timers = input.timers ?? GLOBAL_MEMORY_TIMERS;
196
+ if (!(await providerAnswers(input.provider, timers)))
197
+ return await queuedEntryResult(input, note);
198
+ let answer;
199
+ try {
200
+ answer = await raceTimeout(input.provider.push(saveRequest(note, input.writeMode)), MEMORY_OP_TIMEOUT_MS, timers);
201
+ }
202
+ catch (error) {
203
+ if (!isMemoryPushRejected(error))
204
+ return await queuedEntryResult(input, note);
205
+ await discardRefused(input, note.id);
206
+ await restoreNote(input, note.scope, note.slug, previous);
207
+ throw error;
208
+ }
209
+ await input.outbox.remove(note.id);
210
+ await recordDelivered(input, note, answer);
211
+ await refreshIndex(input);
212
+ return { slug: note.slug, status: answer.status, delivered: true };
213
+ }
214
+ /**
215
+ * Store one note and deliver it to the provider, or queue it for the next sync.
216
+ *
217
+ * The validator decides first, so a refusal leaves the store exactly as it found it.
218
+ * Everything after it runs in the mirror's own order — one save at a time in this process
219
+ * (`orderedByMirrorLocks`) and one writer at a time across processes (the store's locks) —
220
+ * because the "is this key already queued?" answer, the note staged in the mirror and the
221
+ * entry the queue takes are one decision: interleaved, they deliver in the wrong order or
222
+ * leave the mirror on the older note.
223
+ */
224
+ export async function saveMemoryNote(input) {
225
+ const refusal = validateMemoryNoteWrite(input.write, input.source);
226
+ if (refusal !== null)
227
+ throw writeRefused(refusal);
228
+ return await orderedByMirrorLocks(input.store, () => commitSave(input));
229
+ }
230
+ /**
231
+ * The ordered half of a save: take the mirror's lock, and stage and declare under it.
232
+ *
233
+ * A mirror another writer owns is not this save's to change, and nothing is lost by
234
+ * leaving it alone: the note goes on the outbox under its own client id, and the sync that
235
+ * does own the mirror delivers it in sequence. Staging into a mirror someone else is
236
+ * draining or pulling would be the very interleaving the lock is there to prevent.
237
+ *
238
+ * A sync in this process is the one owner that needs no wait: it queues no save of its own,
239
+ * so this write can join the queue immediately. Any other owner has to release the lock
240
+ * before this save stages or declares (`waitForTurn`), preserving sequence allocation even
241
+ * when the holder is paused before enqueueing.
242
+ */
243
+ async function commitSave(input) {
244
+ if (mirrorSyncInFlight(input.store))
245
+ return await queueWithoutMirror(input);
246
+ return await withMirrorLock(input, () => deliverLocked(input));
247
+ }
248
+ /**
249
+ * The answer when another writer owns the mirror: the write is queued and the mirror is
250
+ * left alone.
251
+ *
252
+ * Nothing is staged, so no index is rebuilt either — the mirror still holds what its owner
253
+ * put there, and this note reaches it when a sync delivers the entry. The queue is the one
254
+ * part of the mirror any writer may add to: an entry is a whole file written atomically,
255
+ * named by its sequence and client id.
256
+ */
257
+ async function queueWithoutMirror(input) {
258
+ const note = stagedNote(input);
259
+ return await queueFenced(input, note, saveRequest(note, input.writeMode));
260
+ }
261
+ /** The refusal a note this mirror does not hold — or one that is history — is thrown as. */
262
+ function noteNotFound(input) {
263
+ return new NaxError(`memory note "${memoryNoteKey(input.scope, input.slug)}" is not in this mirror`, "MEMORY_NOTE_NOT_FOUND", { stage: ERROR_STAGE, scope: input.scope, slug: input.slug });
264
+ }
265
+ /** The request one forget is pushed as: its own UUID keys the write, and the reason rides along. */
266
+ function forgetRequest(input) {
267
+ const { scope, slug, writeMode, reason } = input;
268
+ return { kind: "forget", clientId: randomUUID(), scope, slug, writeMode, reason };
269
+ }
270
+ /** Queue a forget this operation could not deliver, and rebuild the index so its marker shows. */
271
+ async function queuedForget(input) {
272
+ await refreshIndex(input);
273
+ return { slug: input.slug, status: FORGET_STATUS_BY_MODE[input.writeMode], delivered: false };
274
+ }
275
+ /**
276
+ * The ordered half of a forget: join a sync that owns the mirror, or take the lock and decide
277
+ * under it. Nothing is read from the mirror before the lock is held, so what a forget retires
278
+ * and what the queue is asked about are both the mirror's current answer rather than a stale one.
279
+ */
280
+ async function commitForget(input) {
281
+ const request = forgetRequest(input);
282
+ if (mirrorSyncInFlight(input.store))
283
+ return await queueFencedForget(input, request);
284
+ return await withMirrorLock(input, () => forgetLocked(input, request));
285
+ }
286
+ /**
287
+ * The answer while a sync of this process owns the mirror: the request is queued, and the
288
+ * mirror is only read.
289
+ *
290
+ * The note is still required — a forget of a slug the mirror does not hold is refused rather
291
+ * than queued — but nothing is deleted and no index is rendered: an index built from a mirror
292
+ * the sync is halfway through changing would publish its half-written state, and the sync's own
293
+ * refresh is the one that counts.
294
+ */
295
+ async function queueFencedForget(input, request) {
296
+ if ((await input.store.readNote(input.scope, input.slug)) === null)
297
+ throw noteNotFound(input);
298
+ await input.outbox.enqueue(request);
299
+ return { slug: input.slug, status: FORGET_STATUS_BY_MODE[input.writeMode], delivered: false };
300
+ }
301
+ /**
302
+ * Retire the note and deliver the request, while the mirror's lock is held.
303
+ *
304
+ * The intent is declared on the queue *before* the note it names is touched. Under `direct`
305
+ * this method deletes that note, and a delete no durable request records is a retire the
306
+ * provider never hears about — the next pull restores the note and the agent's decision is
307
+ * silently undone. Writing the entry first costs one file write and makes every later failure
308
+ * recoverable: a failed delete leaves the entry to be replayed, a transient push failure
309
+ * leaves it queued, and only a permanent refusal takes it back off (with the note put back,
310
+ * under `direct`, because a refusal will never change).
311
+ */
312
+ async function forgetLocked(input, request) {
313
+ const previous = await input.store.readNote(input.scope, input.slug);
314
+ if (previous === null)
315
+ throw noteNotFound(input);
316
+ // Read before the entry goes on: the one this call is about to add cannot be "earlier work".
317
+ const waiting = (await input.outbox.heldKeys()).has(memoryNoteKey(input.scope, input.slug));
318
+ await input.outbox.enqueue(request);
319
+ if (input.writeMode === "direct")
320
+ await input.store.deleteNote(input.scope, input.slug);
321
+ if (waiting)
322
+ return await queuedForget(input);
323
+ return await handOverForget(input, previous, request);
324
+ }
325
+ /**
326
+ * Ask the provider to forget the note this operation already queued.
327
+ *
328
+ * Anything short of an acceptance leaves the entry where it is: the entry is what makes the
329
+ * retire durable, not this attempt. Acceptance and a permanent refusal both take it back off
330
+ * — retrying a refusal would repeat it — and the refusal puts the note back under `direct`.
331
+ */
332
+ async function handOverForget(input, previous, request) {
333
+ const timers = input.timers ?? GLOBAL_MEMORY_TIMERS;
334
+ if (!(await providerAnswers(input.provider, timers)))
335
+ return await queuedForget(input);
336
+ let answer;
337
+ try {
338
+ answer = await raceTimeout(input.provider.push(request), MEMORY_OP_TIMEOUT_MS, timers);
339
+ }
340
+ catch (error) {
341
+ if (!isMemoryPushRejected(error))
342
+ return await queuedForget(input);
343
+ // The refusal is the caller's answer; the entry and the note are put back regardless of
344
+ // whether removing it works, so a refused forget never leaves the note deleted.
345
+ await discardRefused(input, request.clientId);
346
+ if (input.writeMode === "direct")
347
+ await restoreForgotten(input, previous);
348
+ throw error;
349
+ }
350
+ await input.outbox.remove(request.clientId);
351
+ await refreshIndex(input);
352
+ return { slug: input.slug, status: answer.status, delivered: true };
353
+ }
354
+ /**
355
+ * Forget one note: retire it in the mirror and tell the provider, or queue the request.
356
+ *
357
+ * The reason is checked first, before the lock and before anything is written: a justification
358
+ * that is empty, carries a line break or runs past `MEMORY_FORGET_REASON_MAX_CHARS` is refused
359
+ * as `MEMORY_WRITE_REFUSED`, so no such text reaches the request the provider is pushed or the
360
+ * entry a queued forget leaves in the mirror's outbox.
361
+ *
362
+ * A mirror another writer owns is not this forget's to change, so it is coordinated exactly as
363
+ * a save is: this process's chain (`orderedByStore`) orders the forgets and saves of one mirror,
364
+ * and the store's lock file orders them across processes. Without the lock a forget could delete
365
+ * a note while another process's save restored or overwrote it, and the queue's order would
366
+ * describe writes that never happened in that order. A permanent refusal is the one answer
367
+ * undone, under `direct`, and it is rethrown; an absent slug is never queued.
368
+ */
369
+ export async function forgetMemoryNote(input) {
370
+ const refusal = forgetReasonRefusal(input.reason);
371
+ if (refusal !== null)
372
+ throw writeRefused(refusal);
373
+ return await orderedByMirrorLocks(input.store, () => commitForget(input));
374
+ }
375
+ /** The statuses an approval may not bring into force: a note that is already history. */
376
+ const RETIRED_STATUSES = new Set(["superseded", "rejected", "deleted"]);
377
+ /** The note one approval rewrites: the same note, in force, stamped with the caller's clock. */
378
+ function inForce(note, now) {
379
+ return { ...note, status: "active", updatedAt: now().toISOString() };
380
+ }
381
+ /** The mirror's own queue, as an approval reads it: where the index's forget markers live. */
382
+ function mirrorOutbox(store) {
383
+ return createMemoryOutbox({ dir: store.paths.outboxDir });
384
+ }
385
+ /**
386
+ * The mirror's lock as this approval takes it: the caller's seam where it gave one, and the
387
+ * process defaults — including the logger slot — where it did not.
388
+ */
389
+ function lockInputOf(input) {
390
+ return {
391
+ store: input.store,
392
+ logger: input.logger ?? getLogger(),
393
+ now: input.now,
394
+ timers: input.timers,
395
+ isAlive: input.isAlive,
396
+ };
397
+ }
398
+ /**
399
+ * The locked half of an approval: read, decide and rewrite while no other writer of this
400
+ * mirror can change the note in between.
401
+ *
402
+ * The read and the write have to be one decision — that is the whole reason this takes the
403
+ * lock at all — so a note another writer replaced while this waited is the note that gets
404
+ * approved, never the copy this operation would otherwise have read before the wait.
405
+ */
406
+ async function approveLocked(input) {
407
+ const note = await input.store.readNote(input.scope, input.slug);
408
+ if (note === null || RETIRED_STATUSES.has(note.status))
409
+ throw noteNotFound(input);
410
+ if (note.status === "active")
411
+ return note;
412
+ const approved = inForce(note, clockOf(input));
413
+ await input.store.writeNote(approved);
414
+ await refreshIndex({ store: input.store, outbox: mirrorOutbox(input.store) });
415
+ return approved;
416
+ }
417
+ /**
418
+ * Put one pending note in force in this mirror.
419
+ *
420
+ * Only the note the mirror holds is consulted, so approval is a local transition rather than an
421
+ * authorization step: it asks no provider and writes nothing to the queue, and a forget waiting
422
+ * on that queue is still waiting afterwards. What it does read from the queue is the set of
423
+ * notes whose forget is queued, because the index it regenerates carries that marker and an
424
+ * index rendered without it would report a still-pending retire as though it had never been
425
+ * asked for.
426
+ *
427
+ * The read and the rewrite are one decision, so they run under the same ordering and the same
428
+ * lock file a save and a forget use: this is a writer of the mirror, and what it approves must
429
+ * be the note the mirror holds now rather than a copy read before another writer's newer one
430
+ * landed. Waiting is the right answer here, unlike a save's — an approval has no queue to put
431
+ * its write on, so it has to be the writer when it reads.
432
+ *
433
+ * A note already in force is returned exactly as it stands — approving it again is not a
434
+ * rewrite, so its `updatedAt` still says when it was made active. A note that is history is not
435
+ * a note waiting for approval, and neither is one this mirror does not hold: both are
436
+ * `MEMORY_NOTE_NOT_FOUND`, because there is nothing to approve.
437
+ */
438
+ export async function approveMemoryNote(input) {
439
+ return await orderedByMirrorLocks(input.store, () => withMirrorLock(lockInputOf(input), () => approveLocked(input)));
440
+ }
@@ -0,0 +1,35 @@
1
+ import type { MemoryWriteRequest } from "./provider-types.ts";
2
+ /** The write the caller hands over: it owns the content, the outbox may own the id. */
3
+ type OutboxRequest = Omit<MemoryWriteRequest, "clientId"> & {
4
+ clientId?: string;
5
+ };
6
+ /** One queued write as the outbox holds it: the request plus where it sits in the order. */
7
+ export type MemoryOutboxEntry = MemoryWriteRequest & {
8
+ readonly seq: number;
9
+ readonly enqueuedAt: string;
10
+ readonly state: "queued" | "rejected";
11
+ /** The rejection reason once rejected; the request's own reason until then. */
12
+ readonly reason?: string;
13
+ /** A reason the request itself carried (a forget's justification), kept across a rejection. */
14
+ readonly requestReason?: string;
15
+ };
16
+ /** The durable queue of unacknowledged writes. */
17
+ export interface MemoryOutbox {
18
+ /**
19
+ * Take one write, stamped with its sequence. A request the queue cannot carry is refused
20
+ * before any file is written: `MEMORY_WRITE_REFUSED` for a reason past
21
+ * `MEMORY_FORGET_REASON_MAX_CHARS` (or empty, or spanning lines) and
22
+ * `MEMORY_CLIENT_ID_INVALID` for an id that is not one.
23
+ */
24
+ enqueue(request: OutboxRequest): Promise<MemoryOutboxEntry>;
25
+ list(): Promise<MemoryOutboxEntry[]>;
26
+ listInvalid(): Promise<string[]>;
27
+ remove(clientId: string): Promise<void>;
28
+ markRejected(clientId: string, reason: string): Promise<void>;
29
+ heldKeys(kind?: "save" | "forget"): Promise<ReadonlySet<string>>;
30
+ }
31
+ export declare function createMemoryOutbox(input: {
32
+ readonly dir: string;
33
+ readonly now?: () => Date;
34
+ }): MemoryOutbox;
35
+ export {};