@sigrun/sigrun 0.7.0 → 0.9.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 (56) hide show
  1. package/CHANGELOG.md +78 -59
  2. package/README.md +32 -0
  3. package/dist/config.js +47 -0
  4. package/dist/curation/host.js +125 -21
  5. package/dist/curation/runs.js +15 -4
  6. package/dist/extension.d.ts +331 -4
  7. package/dist/harness/boot.js +114 -3
  8. package/dist/harness/complete.js +127 -0
  9. package/dist/harness/decision-tool.js +93 -0
  10. package/dist/harness/decisions.js +477 -0
  11. package/dist/harness/event-bus.js +12 -0
  12. package/dist/harness/interactions.js +81 -0
  13. package/dist/harness/interactive-tools.js +97 -0
  14. package/dist/harness/loader.js +285 -1
  15. package/dist/harness/packages.js +7 -0
  16. package/dist/harness/published-hooks.js +208 -0
  17. package/dist/harness/seam-host.js +149 -1
  18. package/dist/harness/settings.js +41 -0
  19. package/dist/history/git.js +6 -2
  20. package/dist/jobs/fire.js +10 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/notices/events.js +8 -0
  23. package/dist/notices/service.js +297 -0
  24. package/dist/notices/store.js +331 -0
  25. package/dist/notices/validate.js +311 -0
  26. package/dist/server/conversation-ops.js +50 -0
  27. package/dist/server/core-views/skills-tree.js +3 -12
  28. package/dist/server/core-views/skills.js +11 -23
  29. package/dist/server/host-bridge.js +62 -1
  30. package/dist/server/index.js +26 -0
  31. package/dist/server/routes/events.js +20 -0
  32. package/dist/server/routes/extensions.js +5 -0
  33. package/dist/server/routes/interactions.js +48 -1
  34. package/dist/server/routes/meta.js +17 -0
  35. package/dist/server/routes/notices.js +94 -0
  36. package/dist/server/routes/providers.js +65 -8
  37. package/dist/server/routes/submissions.js +3 -0
  38. package/dist/server/routes/users.js +4 -0
  39. package/dist/server/surfaces/dedup.js +6 -0
  40. package/dist/server/visibility.js +30 -0
  41. package/dist/settings/registry.js +18 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  43. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  44. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  45. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  46. package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
  47. package/node_modules/@sigrun/shared/dist/notices.js +49 -0
  48. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  49. package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
  50. package/node_modules/@sigrun/shared/package.json +9 -1
  51. package/package.json +7 -7
  52. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  53. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  54. package/webui/dist/index.html +2 -2
  55. package/webui/dist/assets/index-BI5k-Oci.js +0 -55
  56. package/webui/dist/assets/index-EGluyRXk.css +0 -1
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The notices service (notices Task 3, spec §2/§4): the one mediation
3
+ * point between the store files and every caller — the extension-facing
4
+ * `host.notices` surface (via the host bridge), the HTTP routes (Task
5
+ * 4), and the user-removal path (`removeUser`).
6
+ *
7
+ * Responsibilities the store deliberately does NOT own:
8
+ *
9
+ * - access: a post's recipient must exist, and every conversation the
10
+ * notice names (`conversationId`, plus any `navigate` action's
11
+ * conversation target) must be visible to that user — the service's
12
+ * `visible` dep answers for an ARBITRARY directory user
13
+ * (`visibleToUser`), because the posting extension is never the
14
+ * recipient;
15
+ * - the declared-handler gate: an `extension` action's `name` must be
16
+ * declared in the posting extension's `noticeActions` (the loader's
17
+ * bindings are the registry);
18
+ * - the `notice.changed` feed: the store's `NoticeChange` becomes the
19
+ * hub event (`NoticeChangedInternal` — the wire shape plus the user);
20
+ * - visibility pruning on the user read: `listForUser` re-checks every
21
+ * conversation notice against the CURRENT share state and removes the
22
+ * ones the user can no longer see (a revoked share never leaves the
23
+ * name behind — spec §3 "Lost access");
24
+ * - action dispatch (`runAction`): the surface-action primitives
25
+ * (per-principal rate limit, requestId dedup, the 10 s timeout),
26
+ * the notice lookup, and the clear-on-ok rule.
27
+ *
28
+ * The per-user store cache (`stores` map) exists because constructing a
29
+ * `NoticeStore` is closure-allocation over ONE user area; the store
30
+ * itself caches nothing (every op reads the file under the area mutex),
31
+ * so a stale cache entry can never serve stale data. `removeUser` both
32
+ * evicts the entry and removes the file (spec §3.2 rule 8: the user
33
+ * area stays for the workspace's recipient, so the notices file —
34
+ * personal, never transferred — goes on its own).
35
+ */
36
+ import { ACTION_TIMEOUT_MS } from "../server/surfaces/service.js";
37
+ import { ActionDedup } from "../server/surfaces/dedup.js";
38
+ import { ActionRateLimit } from "../server/surfaces/rate-limit.js";
39
+ import { NoticeStore } from "./store.js";
40
+ import { validateNoticeInput } from "./validate.js";
41
+ export class NoticesService {
42
+ deps;
43
+ stores = new Map();
44
+ dedup = new ActionDedup();
45
+ rateLimit = new ActionRateLimit();
46
+ actionTimeoutMs;
47
+ constructor(deps, options) {
48
+ this.deps = deps;
49
+ this.actionTimeoutMs = options?.actionTimeoutMs ?? ACTION_TIMEOUT_MS;
50
+ }
51
+ /**
52
+ * Post one notice as `owner` (the calling extension's slug): shape
53
+ * validation plus the declared-handler gate, then the access checks
54
+ * (unknown user; an invisible conversation or navigate target), then
55
+ * the store write. Throws `Error("<field>: <message>")` on any
56
+ * rejection — the fail-loud posture.
57
+ */
58
+ async post(owner, input) {
59
+ const declared = this.declaredHandlers(owner);
60
+ const validation = validateNoticeInput(input, declared);
61
+ if (!validation.ok) {
62
+ throw new Error(`${validation.field}: ${validation.message}`);
63
+ }
64
+ const notice = validation.value;
65
+ const record = this.deps.opened.identity.users.get(notice.user);
66
+ if (record === undefined) {
67
+ throw new Error(`user: unknown user "${notice.user}"`);
68
+ }
69
+ if (notice.conversationId !== undefined &&
70
+ !(await this.deps.visible(notice.conversationId, notice.user))) {
71
+ throw new Error(`conversationId: conversation ${notice.conversationId} not visible to user "${notice.user}"`);
72
+ }
73
+ for (const [index, action] of (notice.actions ?? []).entries()) {
74
+ if (action.kind !== "navigate" || !("conversation" in action.target)) {
75
+ continue;
76
+ }
77
+ const target = action.target.conversation;
78
+ if (!(await this.deps.visible(target, notice.user))) {
79
+ throw new Error(`actions[${index}].target: conversation ${target} not visible to user "${notice.user}"`);
80
+ }
81
+ }
82
+ const posted = await this.storeFor(notice.user).post(owner, notice);
83
+ return { id: posted.id };
84
+ }
85
+ /**
86
+ * The calling extension's own clear: by (user, key) or (user, id),
87
+ * always owner-scoped — another extension's notice is never touched.
88
+ */
89
+ async clear(owner, selector) {
90
+ const store = this.storeFor(selector.user);
91
+ if ("key" in selector) {
92
+ await store.clearByKey(owner, selector.key);
93
+ }
94
+ else {
95
+ await store.clearById(owner, selector.id);
96
+ }
97
+ }
98
+ /** The calling extension's own notices for `user` (owner-scoped read). */
99
+ async listForExtension(owner, user) {
100
+ const notices = await this.storeFor(user).list();
101
+ return notices.filter((notice) => notice.owner === owner);
102
+ }
103
+ /**
104
+ * The user's own read (the GET route's source): every notice, minus
105
+ * any conversation notice whose conversation the user can no longer
106
+ * see — those are removed from the file on the same read (spec §3).
107
+ */
108
+ async listForUser(user) {
109
+ const store = this.storeFor(user);
110
+ const notices = await store.list();
111
+ const lost = [];
112
+ for (const notice of notices) {
113
+ if (notice.conversationId === undefined)
114
+ continue;
115
+ if (!(await this.deps.visible(notice.conversationId, user))) {
116
+ lost.push(notice.id);
117
+ }
118
+ }
119
+ if (lost.length === 0)
120
+ return notices;
121
+ for (const id of lost)
122
+ await store.dismiss(id);
123
+ const lostSet = new Set(lost);
124
+ return notices.filter((notice) => !lostSet.has(notice.id));
125
+ }
126
+ /** Dismiss one of the user's notices; `false` when absent. */
127
+ async dismiss(user, id) {
128
+ return await this.storeFor(user).dismiss(id);
129
+ }
130
+ /** Dismiss everything of the user's. */
131
+ async dismissAll(user) {
132
+ await this.storeFor(user).dismissAll();
133
+ }
134
+ /** Remove one conversation's notices for `user` (app-wide ones stay). */
135
+ async clearConversationFor(user, conversationId) {
136
+ await this.storeFor(user).clearConversation(conversationId);
137
+ }
138
+ /**
139
+ * Run one notice's `extension` action (spec §4). The notice must
140
+ * belong to the principal's user (404 otherwise — a foreign id never
141
+ * reveals existence); the action must be a declared `extension`
142
+ * action on it (400); the handler must be loaded right now (409);
143
+ * per-principal rate limit (429). The handler runs under the 10 s
144
+ * action timeout; on `ok` the notice clears unless `keep: true`.
145
+ */
146
+ async runAction(principal, id, actionId, requestId) {
147
+ const user = principal.user;
148
+ // 1. Rate-limit: per PRINCIPAL across all notices (spec §4). A
149
+ // user-less principal's bucket is the empty string — a
150
+ // service-token caller gets rate-limited like any other.
151
+ if (!this.rateLimit.take("notices", "actions", user ?? "")) {
152
+ return { status: 429, body: { error: "rate_limited" } };
153
+ }
154
+ // 2. Dedup BEFORE the notice lookup (an ok:true handler dismisses
155
+ // the notice, so a retry must still replay the FIRST result).
156
+ // The composite carries the user: one user's requestId never
157
+ // replays another user's result.
158
+ const composite = `notice\u0001${user ?? ""}\u0001${id}\u0001${actionId}`;
159
+ if (requestId !== undefined) {
160
+ const hit = this.dedup.check(requestId, composite, actionId);
161
+ if (hit !== undefined && hit !== null) {
162
+ return { status: 200, body: hit.result };
163
+ }
164
+ }
165
+ // 3. The notice lookup (foreign / unknown / user-less → 404, never
166
+ // 403 — a foreign id never reveals existence).
167
+ if (user === undefined) {
168
+ return { status: 404, body: { error: "notice_not_found" } };
169
+ }
170
+ const notice = await this.storeFor(user).get(id);
171
+ if (notice === undefined) {
172
+ return { status: 404, body: { error: "notice_not_found" } };
173
+ }
174
+ // 4. The action find: missing or not kind:"extension" → 400.
175
+ const action = notice.actions?.find((entry) => entry.id === actionId && entry.kind === "extension");
176
+ if (action === undefined) {
177
+ return { status: 400, body: { error: "unknown_action" } };
178
+ }
179
+ // 5. The binding find (the owning extension's declared handlers).
180
+ const handler = this.deps.opened.noticeActionBindings.find((binding) => binding.extension === notice.owner)?.actions[action.name];
181
+ if (handler === undefined) {
182
+ return { status: 409, body: { error: "notice_action_unavailable" } };
183
+ }
184
+ // 6. The handler call, racing the action timeout.
185
+ const payload = action.payload ?? {};
186
+ const controller = new AbortController();
187
+ const result = await this.invokeWithTimeout(() => handler({ principal, notice, signal: controller.signal }, payload), controller);
188
+ if (result.ok && result.keep !== true) {
189
+ await this.storeFor(user).dismiss(notice.id);
190
+ }
191
+ const body = result.ok === true
192
+ ? {
193
+ ok: true,
194
+ ...(result.message === undefined ? {} : { message: result.message }),
195
+ ...(result.navigate === undefined
196
+ ? {}
197
+ : { navigate: result.navigate }),
198
+ }
199
+ : result;
200
+ if (requestId !== undefined) {
201
+ this.dedup.record(requestId, composite, actionId, body);
202
+ }
203
+ return { status: 200, body };
204
+ }
205
+ /**
206
+ * User deletion: remove the user's notices file, then evict the cached
207
+ * store (spec §3.2 rule 8). No change event — the user the events
208
+ * would address is gone.
209
+ */
210
+ async removeUser(user) {
211
+ // The cached store when present, else a throwaway one: the removal
212
+ // needs only the file path, and caching a store for a user who no
213
+ // longer exists would be waste.
214
+ const store = this.stores.get(user) ?? this.createStore(user);
215
+ await store.removeFile();
216
+ this.stores.delete(user);
217
+ }
218
+ /** Test seam: disable (or re-enable) the action rate limiter. */
219
+ setRateLimitDisabled(disabled) {
220
+ this.rateLimit.setDisabled(disabled);
221
+ }
222
+ /** The handler names `owner` declared in its `noticeActions`. */
223
+ declaredHandlers(owner) {
224
+ const binding = this.deps.opened.noticeActionBindings.find((entry) => entry.extension === owner);
225
+ return new Set(Object.keys(binding?.actions ?? {}));
226
+ }
227
+ /** The cached per-user store, constructed on first use. */
228
+ storeFor(user) {
229
+ const cached = this.stores.get(user);
230
+ if (cached !== undefined)
231
+ return cached;
232
+ const store = this.createStore(user);
233
+ this.stores.set(user, store);
234
+ return store;
235
+ }
236
+ /** One fresh per-user store (never cached — `storeFor` caches). */
237
+ createStore(user) {
238
+ return new NoticeStore({
239
+ userArea: this.deps.userArea(user),
240
+ user,
241
+ userCreatedAt: () => this.deps.opened.identity.users.get(user)?.createdAt,
242
+ now: this.deps.now,
243
+ onChange: (change) => {
244
+ // The hub copy carries the user (NoticeChangedInternal); the
245
+ // events route strips it before delivery (spec §4).
246
+ const event = {
247
+ type: "notice.changed",
248
+ user: change.user,
249
+ ids: change.ids,
250
+ change: change.change,
251
+ };
252
+ this.deps.publish(event);
253
+ },
254
+ log: this.deps.log,
255
+ });
256
+ }
257
+ /**
258
+ * Race the handler against the 10 s action timeout (spec §4): on a
259
+ * timeout the handler is abandoned (its late settlement is dropped)
260
+ * and the action answers the `timed out` failure, the notice stays.
261
+ */
262
+ async invokeWithTimeout(invoke, controller) {
263
+ let timer;
264
+ try {
265
+ const outcome = await Promise.race([
266
+ invoke().then((result) => ({
267
+ kind: "done",
268
+ result,
269
+ }), (cause) => ({
270
+ kind: "threw",
271
+ cause,
272
+ })),
273
+ new Promise((resolve) => {
274
+ timer = setTimeout(() => resolve({ kind: "timedout" }), this.actionTimeoutMs);
275
+ }),
276
+ ]);
277
+ if (outcome.kind === "timedout") {
278
+ return { ok: false, error: { message: "timed out" } };
279
+ }
280
+ if (outcome.kind === "threw") {
281
+ const cause = outcome.cause;
282
+ return {
283
+ ok: false,
284
+ error: {
285
+ message: cause instanceof Error ? cause.message : String(cause),
286
+ },
287
+ };
288
+ }
289
+ return outcome.result;
290
+ }
291
+ finally {
292
+ if (timer !== undefined)
293
+ clearTimeout(timer);
294
+ controller.abort();
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The per-user notice store (notices spec §3/§5): one `notices.json` per
3
+ * user area, `createdAt`-stamped, written atomically, and never trusted
4
+ * on the way back in.
5
+ *
6
+ * The store is the ONLY writer of the file, and it is deliberately
7
+ * stateless: every operation reads the file under the module-level
8
+ * per-`userArea` mutex, so two store instances (a restarted process, a
9
+ * second server) sharing one area cannot lose each other's posts — the
10
+ * mutex spans the whole read-modify-write, not just the rename. The file
11
+ * itself is small (at most `NOTICE_LIMITS.perUser` entries), so the read
12
+ * is cheaper than the bookkeeping a cache would need to stay honest.
13
+ *
14
+ * Two things the store enforces that the shared validator does not:
15
+ *
16
+ * - the three core-assigned fields (`id`, `owner`, `at`) are checked
17
+ * here (UUID, extension slug, ISO instant) because
18
+ * `validateNoticeShape` deliberately rebuilds its answer from the
19
+ * input's validated fields only — a stored `id` is never its input;
20
+ * - the `userCreatedAt` stamp: a file written for a previous directory
21
+ * record of the same name (deleted and re-created) is discarded, the
22
+ * belt to the explicit removal on user deletion.
23
+ *
24
+ * Load re-validation uses `validateNoticeShape` and NOT
25
+ * `validateNoticeInput`: a stored notice whose extension is disabled or
26
+ * unloaded must stay dismissible (spec §3), so the declared-handler gate
27
+ * belongs to the post path only.
28
+ *
29
+ * Change events are the service's `notice.changed` feed: every mutation
30
+ * is ONE atomic write, and the `onChange` calls for it fire afterwards,
31
+ * in the order the spec names — the cleared old notice, the evicted
32
+ * notices, then the posted new one.
33
+ */
34
+ import { randomUUID } from "node:crypto";
35
+ import { mkdir, readFile, rm } from "node:fs/promises";
36
+ import { dirname, join } from "node:path";
37
+ import { NOTICE_LIMITS } from "@sigrun/shared/notices";
38
+ import { writeFileAtomic } from "../harness/stores.js";
39
+ import { validateNoticeShape } from "./validate.js";
40
+ /** The file format version; a different `v` reads as an empty file. */
41
+ const FILE_VERSION = 1;
42
+ /** A `randomUUID()` id: lowercase hex, version/variant nibbles unconstrained. */
43
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+ /** An extension slug (`slugifyName`'s alphabet, non-empty). */
45
+ const OWNER_RE = /^[a-z0-9][a-z0-9_-]{0,127}$/;
46
+ /** The instant shape `Date#toISOString` writes. */
47
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
48
+ /**
49
+ * userArea → the tail of that area's mutation chain. Module-level, so
50
+ * every `NoticeStore` over one area serializes against every other (the
51
+ * brief's per-`userArea` mutex), not just against itself.
52
+ */
53
+ const queues = new Map();
54
+ /**
55
+ * Run `section` under the user area's mutex: it starts only after the
56
+ * previous section settles (either way — a failed write must not wedge
57
+ * the chain), and the next waiter sees this section's tail. The returned
58
+ * promise carries `section`'s own settlement to its caller.
59
+ */
60
+ function withUserAreaLock(userArea, section) {
61
+ const tail = queues.get(userArea) ?? Promise.resolve();
62
+ const run = tail.then(section, section);
63
+ queues.set(userArea, run.then(() => undefined, () => undefined));
64
+ return run;
65
+ }
66
+ /**
67
+ * The per-user notice store. Construct one per user; instances over one
68
+ * `userArea` share the mutation chain and therefore see each other's
69
+ * writes.
70
+ */
71
+ export class NoticeStore {
72
+ options;
73
+ path;
74
+ /** The stamp-mismatch line is logged once per instance, not per read. */
75
+ stampWarned = false;
76
+ constructor(options) {
77
+ this.options = options;
78
+ this.path = join(options.userArea, "notices.json");
79
+ }
80
+ /** This user's notices, newest first. */
81
+ async list() {
82
+ return newestFirst(await this.readFile());
83
+ }
84
+ /** One notice by id, or `undefined`. */
85
+ async get(id) {
86
+ const notices = await this.readFile();
87
+ return notices.find((notice) => notice.id === id);
88
+ }
89
+ /**
90
+ * Post one notice from `owner`: a same-(owner, key) notice is replaced
91
+ * (the new notice mints a NEW id, so a stale click answers 404 rather
92
+ * than hitting newer content), then the caps evict, then the post is
93
+ * announced.
94
+ */
95
+ async post(owner, input) {
96
+ return await this.mutate((notices) => {
97
+ const working = [...notices];
98
+ const changes = [];
99
+ // 1. A same-(owner, key) replace removes the old notice first.
100
+ if (input.key !== undefined) {
101
+ const index = working.findIndex((notice) => notice.owner === owner && notice.key === input.key);
102
+ if (index !== -1) {
103
+ const [replaced] = working.splice(index, 1);
104
+ changes.push(this.change("cleared", [replaced.id]));
105
+ }
106
+ }
107
+ // 2. The new notice is appended (`id`/`owner`/`at` win over any
108
+ // same-named input field — they are core-assigned). The instant
109
+ // is read INSIDE the lock, so the file order and the `at` order
110
+ // agree even when two posts race.
111
+ const notice = {
112
+ ...input,
113
+ id: randomUUID(),
114
+ owner,
115
+ at: this.options.now().toISOString(),
116
+ };
117
+ working.push(notice);
118
+ // 3. The caps evict, oldest first: this owner's, then the user's
119
+ // across every extension. Both ride ONE cleared event.
120
+ const evicted = [];
121
+ while (working.filter((entry) => entry.owner === owner).length >
122
+ NOTICE_LIMITS.perExtension) {
123
+ const id = evictOldest(working, (entry) => entry.owner === owner);
124
+ if (id !== undefined)
125
+ evicted.push(id);
126
+ }
127
+ while (working.length > NOTICE_LIMITS.perUser) {
128
+ const id = evictOldest(working, () => true);
129
+ if (id !== undefined)
130
+ evicted.push(id);
131
+ }
132
+ if (evicted.length > 0)
133
+ changes.push(this.change("cleared", evicted));
134
+ // 4. The post is announced last.
135
+ changes.push(this.change("posted", [notice.id]));
136
+ return { value: notice, notices: working, changes };
137
+ });
138
+ }
139
+ /** Remove this owner's notice carrying `key`; returns the removed ids. */
140
+ async clearByKey(owner, key) {
141
+ return await this.remove((notice) => notice.owner === owner && notice.key === key);
142
+ }
143
+ /** Remove this owner's notice by id (the extension's own clear). */
144
+ async clearById(owner, id) {
145
+ return await this.remove((notice) => notice.owner === owner && notice.id === id);
146
+ }
147
+ /** The user dismisses one notice (any extension's); false when absent. */
148
+ async dismiss(id) {
149
+ const removed = await this.remove((notice) => notice.id === id);
150
+ return removed.length > 0;
151
+ }
152
+ /** The user dismisses everything; returns every removed id. */
153
+ async dismissAll() {
154
+ return await this.remove(() => true);
155
+ }
156
+ /** Remove the notices tied to one conversation (app-wide ones stay). */
157
+ async clearConversation(conversationId) {
158
+ return await this.remove((notice) => notice.conversationId === conversationId);
159
+ }
160
+ /**
161
+ * Remove the file outright (user deletion). No change event: the user
162
+ * the events would address is gone.
163
+ */
164
+ async removeFile() {
165
+ await withUserAreaLock(this.options.userArea, async () => {
166
+ await rm(this.path, { force: true });
167
+ });
168
+ }
169
+ /**
170
+ * Remove every notice `predicate` accepts, as one write and one
171
+ * `cleared` change. Returns the removed ids, newest first; nothing
172
+ * matched = no write and no change.
173
+ */
174
+ async remove(predicate) {
175
+ return await this.mutate((notices) => {
176
+ const removed = notices.filter(predicate);
177
+ if (removed.length === 0)
178
+ return { value: [] };
179
+ const ids = newestFirst(removed).map((notice) => notice.id);
180
+ return {
181
+ value: ids,
182
+ notices: notices.filter((notice) => !predicate(notice)),
183
+ changes: [this.change("cleared", ids)],
184
+ };
185
+ });
186
+ }
187
+ /** Run one read-modify-write under the area's mutex, then emit. */
188
+ async mutate(step) {
189
+ return await withUserAreaLock(this.options.userArea, async () => {
190
+ const mutation = step(await this.readFile());
191
+ if (mutation.notices !== undefined) {
192
+ await this.writeFile(mutation.notices);
193
+ for (const change of mutation.changes ?? [])
194
+ this.options.onChange(change);
195
+ }
196
+ return mutation.value;
197
+ });
198
+ }
199
+ /** One atomic write (stage + rename), one file. */
200
+ async writeFile(notices) {
201
+ const file = {
202
+ v: FILE_VERSION,
203
+ userCreatedAt: this.options.userCreatedAt() ?? null,
204
+ notices,
205
+ };
206
+ await mkdir(dirname(this.path), { recursive: true });
207
+ // 0600: one user's messages, never shared state — the sandbox policy
208
+ // keeps the agent out of the user area, this is the belt.
209
+ await writeFileAtomic(this.path, `${JSON.stringify(file, null, "\t")}\n`, 0o600);
210
+ }
211
+ /**
212
+ * Read and re-validate the file. Every failure mode reads as an empty
213
+ * file (a missing one is simply empty) — the file is never trusted,
214
+ * and a notice system must not fail closed on its own storage.
215
+ */
216
+ async readFile() {
217
+ let text;
218
+ try {
219
+ text = await readFile(this.path, "utf8");
220
+ }
221
+ catch (error) {
222
+ if (error.code === "ENOENT")
223
+ return [];
224
+ this.options.log(`notices: cannot read ${this.path}: ${String(error)}`);
225
+ return [];
226
+ }
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(text);
230
+ }
231
+ catch (error) {
232
+ this.options.log(`notices: discarding ${this.path}: unparseable JSON (${String(error)})`);
233
+ return [];
234
+ }
235
+ if (typeof parsed !== "object" ||
236
+ parsed === null ||
237
+ Array.isArray(parsed) ||
238
+ parsed.v !== FILE_VERSION ||
239
+ !Array.isArray(parsed.notices)) {
240
+ this.options.log(`notices: discarding ${this.path}: not a version ${FILE_VERSION} notice file`);
241
+ return [];
242
+ }
243
+ const file = parsed;
244
+ // The stamp: a file written for a previous directory record of the
245
+ // same name (a deleted and re-created username) is void.
246
+ const stamped = file.userCreatedAt ?? null;
247
+ const current = this.options.userCreatedAt() ?? null;
248
+ if (stamped !== current) {
249
+ if (!this.stampWarned) {
250
+ this.stampWarned = true;
251
+ this.options.log(`notices: discarding ${this.path}: stamped for directory record ${String(stamped)}, current is ${String(current)}`);
252
+ }
253
+ return [];
254
+ }
255
+ const notices = [];
256
+ for (const entry of file.notices) {
257
+ const loaded = acceptEntry(entry);
258
+ if (!loaded.ok) {
259
+ this.options.log(`notices: dropped an entry in ${this.path}: ${loaded.reason}`);
260
+ continue;
261
+ }
262
+ notices.push(loaded.notice);
263
+ }
264
+ return notices;
265
+ }
266
+ change(change, ids) {
267
+ return { user: this.options.user, ids, change };
268
+ }
269
+ }
270
+ /**
271
+ * Accept one stored entry: the shared shape validation (no handler
272
+ * check), then the three core-assigned fields the shared validator never
273
+ * sees.
274
+ */
275
+ function acceptEntry(entry) {
276
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
277
+ return { ok: false, reason: "not an object" };
278
+ }
279
+ const record = entry;
280
+ const shape = validateNoticeShape(record);
281
+ if (!shape.ok) {
282
+ return { ok: false, reason: `${shape.field}: ${shape.message}` };
283
+ }
284
+ const id = record.id;
285
+ if (typeof id !== "string" || !ID_RE.test(id)) {
286
+ return { ok: false, reason: "id: not a UUID" };
287
+ }
288
+ const owner = record.owner;
289
+ if (typeof owner !== "string" || !OWNER_RE.test(owner)) {
290
+ return { ok: false, reason: "owner: not an extension slug" };
291
+ }
292
+ const at = record.at;
293
+ if (typeof at !== "string" ||
294
+ !ISO_RE.test(at) ||
295
+ !Number.isFinite(Date.parse(at))) {
296
+ return { ok: false, reason: "at: not an ISO timestamp" };
297
+ }
298
+ return { ok: true, notice: { ...shape.value, id, owner, at } };
299
+ }
300
+ /** The notices newest first: by `at`, ties by file position (later = newer). */
301
+ function newestFirst(notices) {
302
+ return notices
303
+ .map((notice, index) => ({ notice, index }))
304
+ .sort((left, right) => {
305
+ const byAt = Date.parse(right.notice.at) - Date.parse(left.notice.at);
306
+ return byAt !== 0 ? byAt : right.index - left.index;
307
+ })
308
+ .map((entry) => entry.notice);
309
+ }
310
+ /**
311
+ * Remove the OLDEST notice `match` accepts, in place (earliest `at`; ties
312
+ * keep the earliest file position), returning its id. `undefined` when
313
+ * nothing matched.
314
+ */
315
+ function evictOldest(notices, match) {
316
+ let index = -1;
317
+ let oldest = Number.POSITIVE_INFINITY;
318
+ for (let position = 0; position < notices.length; position += 1) {
319
+ const notice = notices[position];
320
+ if (!match(notice))
321
+ continue;
322
+ const time = Date.parse(notice.at);
323
+ if (time < oldest) {
324
+ oldest = time;
325
+ index = position;
326
+ }
327
+ }
328
+ if (index === -1)
329
+ return undefined;
330
+ return notices.splice(index, 1)[0].id;
331
+ }