@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.
- package/CHANGELOG.md +78 -59
- package/README.md +32 -0
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +331 -4
- package/dist/harness/boot.js +114 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +285 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +149 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/notices/events.js +8 -0
- package/dist/notices/service.js +297 -0
- package/dist/notices/store.js +331 -0
- package/dist/notices/validate.js +311 -0
- package/dist/server/conversation-ops.js +50 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +62 -1
- package/dist/server/index.js +26 -0
- package/dist/server/routes/events.js +20 -0
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/notices.js +94 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/server/routes/users.js +4 -0
- package/dist/server/surfaces/dedup.js +6 -0
- package/dist/server/visibility.js +30 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
- package/node_modules/@sigrun/shared/dist/notices.js +49 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BI5k-Oci.js +0 -55
- 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
|
+
}
|