@sigrun/sigrun 0.0.0-stage → 0.1.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 +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §5 HTTP server foundation (spec §5).
|
|
3
|
+
*
|
|
4
|
+
* `createServer` builds a Fastify app over one `OpenedHarness` — a thin
|
|
5
|
+
* layer: conversation endpoints serialize pi-durable's native
|
|
6
|
+
* `ConversationView` (via `views.ts`), `GET /v1/meta` reports boot facts.
|
|
7
|
+
* The factory registers routes and returns; it never listens (the deploy
|
|
8
|
+
* entry listens; tests use `app.inject()`).
|
|
9
|
+
*
|
|
10
|
+
* Route modules registered here, each a pure `(app, ctx)` registrar over
|
|
11
|
+
* the shared `ServerContext`: the deploy probes (`routes/probes.ts`),
|
|
12
|
+
* the boot-fact routes (`routes/meta.ts`), the conversation lifecycle +
|
|
13
|
+
* session-meta routes incl. the JSONL import parser
|
|
14
|
+
* (`routes/conversations.ts`), the SSE view stream (`routes/stream.ts`),
|
|
15
|
+
* the submission lifecycle routes (`routes/submissions.ts`), the
|
|
16
|
+
* interaction + ambient seam routes (docs/approval-ux-seams.md,
|
|
17
|
+
* `routes/interactions.ts`), the workspace file routes
|
|
18
|
+
* (`routes/files.ts`), the provider surface routes
|
|
19
|
+
* (`routes/providers.ts`), the settings store routes
|
|
20
|
+
* (`routes/settings.ts`), and the Prometheus text exposition
|
|
21
|
+
* (`routes/metrics.ts`, W2 Task 8). This module only composes: the Fastify
|
|
22
|
+
* instance, the identity hook, the webui mount, the reserved-root
|
|
23
|
+
* ensure, and the register calls — no route handler lives here.
|
|
24
|
+
*
|
|
25
|
+
* **Conversation listing (durable):** `GET /v1/conversations` (in
|
|
26
|
+
* `routes/conversations.ts`) scans storage — `scanConversations` over the
|
|
27
|
+
* handle `openHarness` exposes — so the listing is the DURABLE set:
|
|
28
|
+
* conversations other processes or earlier servers created (a restart
|
|
29
|
+
* forgets nothing), and conversations tasks/tools created inside
|
|
30
|
+
* pi-durable (the cron case) all list. The harness itself has no
|
|
31
|
+
* enumeration; the storage scan is authoritative.
|
|
32
|
+
*/
|
|
33
|
+
import Fastify from "fastify";
|
|
34
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
35
|
+
import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
|
|
36
|
+
import { effectiveMethods } from "../config.js";
|
|
37
|
+
import { registerAuthPipeline } from "./auth/pipeline.js";
|
|
38
|
+
import { isLoopbackHost } from "./auth/loopback.js";
|
|
39
|
+
import { storeAgentChange } from "./helpers.js";
|
|
40
|
+
import { registerCoreOwnershipParticipant } from "./ownership.js";
|
|
41
|
+
import { createHostBridge } from "./host-bridge.js";
|
|
42
|
+
import { registerJobsOwnershipParticipant } from "../jobs/transfer.js";
|
|
43
|
+
import { registerAuthRoutes } from "./routes/auth.js";
|
|
44
|
+
import { registerConversationRoutes } from "./routes/conversations.js";
|
|
45
|
+
import { registerEventRoutes } from "./routes/events.js";
|
|
46
|
+
import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
|
|
47
|
+
import { registerExtensionRoutes as mountExtensionRoutes } from "./routes/extension-routes.js";
|
|
48
|
+
import { registerFileRoutes } from "./routes/files.js";
|
|
49
|
+
import { registerJobRoutes } from "./routes/jobs.js";
|
|
50
|
+
import { registerInteractionRoutes } from "./routes/interactions.js";
|
|
51
|
+
import { registerMetaRoutes } from "./routes/meta.js";
|
|
52
|
+
import { registerMetricsRoutes } from "./routes/metrics.js";
|
|
53
|
+
import { registerOnboardingRoutes } from "./routes/onboarding.js";
|
|
54
|
+
import { registerProbeRoutes } from "./routes/probes.js";
|
|
55
|
+
import { registerProviderRoutes } from "./routes/providers.js";
|
|
56
|
+
import { registerSettingsRoutes } from "./routes/settings.js";
|
|
57
|
+
import { registerStreamRoutes } from "./routes/stream.js";
|
|
58
|
+
import { startNotifications } from "./notifications.js";
|
|
59
|
+
import { registerSubmissionRoutes } from "./routes/submissions.js";
|
|
60
|
+
import { registerUserRoutes } from "./routes/users.js";
|
|
61
|
+
import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
62
|
+
/**
|
|
63
|
+
* Build the §5 server over one opened harness.
|
|
64
|
+
*
|
|
65
|
+
* Ensures the reserved root conversation exists at factory time (the §5
|
|
66
|
+
* default), so the first list serves it.
|
|
67
|
+
*
|
|
68
|
+
* @param opened the booted harness (from `openHarness`) — the server serves
|
|
69
|
+
* exactly this harness's conversations.
|
|
70
|
+
* @param config parsed sigrun config; the listen entry consumes
|
|
71
|
+
* `config.port`, the meta route derives `health.backup` from
|
|
72
|
+
* `config.backup` presence, and `capabilities.backup` from the same
|
|
73
|
+
* presence (the §5 backup affordance gate). Identity is the W1 request
|
|
74
|
+
* pipeline (`auth/pipeline.ts`, registered FIRST below): Host/Origin
|
|
75
|
+
* checks, the public surface, the env service token, the setup gate,
|
|
76
|
+
* then the configured methods — the resolved principal stamps
|
|
77
|
+
* `SessionMeta.owner` at create/fork/import.
|
|
78
|
+
* @param options.listenHost the host the server will bind (default
|
|
79
|
+
* `"0.0.0.0"`); the pipeline's Host check and the default method list
|
|
80
|
+
* (`effectiveMethods`) both derive from it.
|
|
81
|
+
* @param contributedBundles the extension-contributed webui-bundle registry
|
|
82
|
+
* (build-and-webui Task 6 threads it here from boot; the serve-time
|
|
83
|
+
* resolution (Task 5) selects a NAME selector against it — an empty
|
|
84
|
+
* registry is the no-extension deploy).
|
|
85
|
+
* @param options.env the boot env (`process.env` at the deploy entry) — the
|
|
86
|
+
* `/v1/settings` `effective` block classifies env-sourced server keys
|
|
87
|
+
* against it (spec §6.6). Defaults to `{}` for hand-built test servers
|
|
88
|
+
* (every key then reports file/default, which is accurate for an
|
|
89
|
+
* env-less boot).
|
|
90
|
+
*/
|
|
91
|
+
export async function createServer(opened, config, contributedBundles = [], options) {
|
|
92
|
+
const app = Fastify({ logger: false });
|
|
93
|
+
const listenHost = options?.listenHost ?? "0.0.0.0";
|
|
94
|
+
const env = options?.env ?? {};
|
|
95
|
+
// The one body-limit error shape (spec §6.2, W2): Fastify's
|
|
96
|
+
// `FST_ERR_CTP_BODY_TOO_LARGE` (raised by any route's `bodyLimit` — the
|
|
97
|
+
// raw upload PUT, the base64 POST, the JSONL import) becomes the
|
|
98
|
+
// sigrun `413 {error: "too_large", maxBytes}` with the limit the
|
|
99
|
+
// failing route actually enforced. Everything else passes through the
|
|
100
|
+
// default error serialization unchanged.
|
|
101
|
+
app.setErrorHandler((error, request, reply) => {
|
|
102
|
+
if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
|
|
103
|
+
return reply.code(413).send({
|
|
104
|
+
error: "too_large",
|
|
105
|
+
maxBytes: request.routeOptions.bodyLimit,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return reply.send(error);
|
|
109
|
+
});
|
|
110
|
+
const methods = effectiveMethods(config.auth, listenHost);
|
|
111
|
+
// The auth posture warnings (W1 Task 3, the plan's Global
|
|
112
|
+
// Constraints): once per server, never per request.
|
|
113
|
+
if (methods.length === 0 && !isLoopbackHost(listenHost)) {
|
|
114
|
+
console.warn(`sigrun: authentication is DISABLED (methods: []) on a non-loopback bind (${listenHost}) — any reachable client is trusted`);
|
|
115
|
+
}
|
|
116
|
+
const broadTrusted = config.auth.proxy.trustedProxies.some((cidr) => {
|
|
117
|
+
const slash = cidr.indexOf("/");
|
|
118
|
+
if (slash === -1)
|
|
119
|
+
return false;
|
|
120
|
+
const prefix = Number(cidr.slice(slash + 1));
|
|
121
|
+
const v6 = cidr.includes(":");
|
|
122
|
+
return v6 ? prefix <= 32 : prefix <= 8;
|
|
123
|
+
});
|
|
124
|
+
if (methods.includes("proxy") && broadTrusted) {
|
|
125
|
+
console.warn("sigrun: auth.proxy.trustedProxies includes a very broad range — a forward-auth method trusts every peer it matches");
|
|
126
|
+
}
|
|
127
|
+
// The webui channel bundle (Task 20, spec §4.1/§6; serve-time resolution
|
|
128
|
+
// is build-and-webui Task 5): resolve enabled-gate → contributed NAME →
|
|
129
|
+
// PATH → default against `config.webui` and the contributed registry,
|
|
130
|
+
// then mount the static bundle + SPA fallback when one resolves. An
|
|
131
|
+
// explicit-but-unresolvable selector rejects here (fail loud, per the
|
|
132
|
+
// deploy entry's boot contract). Explicit routes win over the static
|
|
133
|
+
// wildcard, so the /v1 API + probes are unaffected by the mount.
|
|
134
|
+
const webuiBundle = await resolveWebuiBundle(config, contributedBundles);
|
|
135
|
+
if (webuiBundle !== undefined) {
|
|
136
|
+
await serveWebuiBundle(app, webuiBundle);
|
|
137
|
+
}
|
|
138
|
+
// The merged slash-command catalog (W5 Task 3): ONE build here — the
|
|
139
|
+
// core table plus every loaded extension's `commands` declarations —
|
|
140
|
+
// so the catalog route, the meta collision record, and the invoke
|
|
141
|
+
// route share one merge (and its warnings fire once, not per route).
|
|
142
|
+
const commandCatalog = buildCommandCatalog(SLASH_COMMANDS, opened.extensionCommands);
|
|
143
|
+
// The reserved root is ensured before any route runs. The listing is
|
|
144
|
+
// durable (the module docstring's "Conversation listing (durable)"
|
|
145
|
+
// section): it scans storage, so this server serves every recorded
|
|
146
|
+
// conversation, not only its own creates.
|
|
147
|
+
// The root conversation's agent carries the settings store's
|
|
148
|
+
// sigrun-resolved fields too (Task 8b): the root is created once, so its
|
|
149
|
+
// tool filter / thinking override snapshot at factory time — later
|
|
150
|
+
// creates re-read the store.
|
|
151
|
+
const root = await opened.harness.root(BACKGROUND_CONTEXT, {
|
|
152
|
+
agent: storeAgentChange(opened, opened.defaultModel),
|
|
153
|
+
});
|
|
154
|
+
const rootId = root.id;
|
|
155
|
+
// The shared context every route module receives: the opened harness,
|
|
156
|
+
// the config, the root id (computed once, never per request), the
|
|
157
|
+
// resolved webui bundle, the auth-derivation inputs (listenHost + the
|
|
158
|
+
// effective method list), and the boot env (the settings routes'
|
|
159
|
+
// `effective` block reads it).
|
|
160
|
+
const ctx = {
|
|
161
|
+
opened,
|
|
162
|
+
config,
|
|
163
|
+
rootId,
|
|
164
|
+
webuiBundle,
|
|
165
|
+
commandCatalog,
|
|
166
|
+
listenHost,
|
|
167
|
+
methods,
|
|
168
|
+
env,
|
|
169
|
+
watchersByConversation: new Map(),
|
|
170
|
+
};
|
|
171
|
+
// The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
|
|
172
|
+
// server's context, bound onto every seam host — the extension-facing
|
|
173
|
+
// conversations/users/access/identity surface then runs the SAME
|
|
174
|
+
// conversation ops the routes below run (never a second
|
|
175
|
+
// implementation). Bound here, after the context exists and before any
|
|
176
|
+
// route or extension code can serve a request; calls issued earlier
|
|
177
|
+
// (a factory body) have been waiting on exactly this assignment.
|
|
178
|
+
opened.bindHostBridge(createHostBridge(ctx));
|
|
179
|
+
// The notifications surface (W5 Task 6, spec §9.6): one hub
|
|
180
|
+
// subscription turning host events into per-recipient
|
|
181
|
+
// NotificationEvents for every collected notifier contribution,
|
|
182
|
+
// delivered on the event bus's bounded side lane. Started after the
|
|
183
|
+
// host bridge binds (a notify ctx's `host` serves real calls from
|
|
184
|
+
// the first event) and never stopped — the surface rides the server
|
|
185
|
+
// lifetime, like the routes above.
|
|
186
|
+
startNotifications(ctx);
|
|
187
|
+
// The pipeline is registered FIRST (W1 Task 3): it replaces the naive
|
|
188
|
+
// `X-Sigrun-User` hook — Host/Origin checks, the public surface, the
|
|
189
|
+
// env service token, the setup gate, and method resolution stamp
|
|
190
|
+
// `request.principal` before any route runs.
|
|
191
|
+
registerAuthPipeline(app, ctx);
|
|
192
|
+
registerAuthRoutes(app, ctx);
|
|
193
|
+
registerOnboardingRoutes(app, ctx);
|
|
194
|
+
registerUserRoutes(app, ctx);
|
|
195
|
+
registerProbeRoutes(app, ctx);
|
|
196
|
+
registerMetaRoutes(app, ctx);
|
|
197
|
+
registerMetricsRoutes(app, ctx);
|
|
198
|
+
registerConversationRoutes(app, ctx);
|
|
199
|
+
registerStreamRoutes(app, ctx);
|
|
200
|
+
registerEventRoutes(app, ctx);
|
|
201
|
+
registerSubmissionRoutes(app, ctx);
|
|
202
|
+
registerInteractionRoutes(app, ctx);
|
|
203
|
+
registerFileRoutes(app, ctx);
|
|
204
|
+
registerProviderRoutes(app, ctx);
|
|
205
|
+
registerSettingsRoutes(app, ctx);
|
|
206
|
+
registerExtensionSettingsRoutes(app, ctx);
|
|
207
|
+
// The extension HTTP routes (W5 Task 2, spec §9.2): every collected
|
|
208
|
+
// `routes` contribution mounted at `/v1/ext/<slug>/<path>` behind the
|
|
209
|
+
// web-standard Request/Response adapter — per-route auth rides the
|
|
210
|
+
// pipeline's stage-4b public skip and this module's admin check.
|
|
211
|
+
mountExtensionRoutes(app, ctx);
|
|
212
|
+
// The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
|
|
213
|
+
// run-now + history surface over `OpenedHarness.jobs` (Task 3's
|
|
214
|
+
// service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
|
|
215
|
+
// (409 `job_declared`); the history routes answer for retired jobs
|
|
216
|
+
// until retention trims them.
|
|
217
|
+
registerJobRoutes(app, ctx);
|
|
218
|
+
// The core ownership participant (W1 Task 4): registered FIRST so
|
|
219
|
+
// conversations transfer before any extension's `userDeleted`
|
|
220
|
+
// observer runs (W4's jobs participant registers behind it).
|
|
221
|
+
registerCoreOwnershipParticipant(ctx);
|
|
222
|
+
// The jobs ownership participant (W4 Task 3, spec §5.8/§8.3): every
|
|
223
|
+
// `api` job record owned by the deleted user moves to the transferee
|
|
224
|
+
// through `writeJobChange` (a NEW hash — the owner rides the spec
|
|
225
|
+
// identity — so version bumps + a `changedBy:"transfer"` revision,
|
|
226
|
+
// and the follow-up reconcile recreates the executor task). File jobs
|
|
227
|
+
// whose DECLARED owner was the deleted user are not touched here; the
|
|
228
|
+
// next boot's reconciliation warns (the declared owner no longer
|
|
229
|
+
// exists — the job runs ownerless until the file is edited).
|
|
230
|
+
registerJobsOwnershipParticipant(ctx);
|
|
231
|
+
return app;
|
|
232
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notifications surface (core hardening W5 Task 6, spec §9.6): ONE
|
|
3
|
+
* subscription on the event hub that turns host events into
|
|
4
|
+
* per-recipient `NotificationEvent`s and delivers them to every
|
|
5
|
+
* collected notifier contribution (`OpenedHarness.notifierBindings`)
|
|
6
|
+
* through the event bus's bounded side lane (`eventBus.runBounded`).
|
|
7
|
+
*
|
|
8
|
+
* Emitted events (spec §9.6):
|
|
9
|
+
*
|
|
10
|
+
* - `interaction.pending` — always. An agent needs a human.
|
|
11
|
+
* - `job.failed` — always. A job's run record settled `failed` (read
|
|
12
|
+
* off the hub's `job.changed` carries; deduped per failed RUN, since
|
|
13
|
+
* `job.changed` fires for every record change and a failed `lastRun`
|
|
14
|
+
* stays in the row until the next settle).
|
|
15
|
+
* - `run.settled` — per the LIVE `notifications.runSettled` setting:
|
|
16
|
+
* `"unwatched"` (default) only when no `/stream` client is attached
|
|
17
|
+
* (the away-from-desk case — `ServerContext.watchersByConversation`),
|
|
18
|
+
* `"cron"` only for job-run conversations (`SessionMeta.source ===
|
|
19
|
+
* "cron"`), `"always"`, `"never"`.
|
|
20
|
+
*
|
|
21
|
+
* Recipients: each event goes to the resource's owner. For
|
|
22
|
+
* `interaction.pending` it ALSO goes to every directory user with
|
|
23
|
+
* `write` access (`accessOf`) — anyone who can answer.
|
|
24
|
+
*
|
|
25
|
+
* Delivery is best-effort: each notify call is deadline-bounded on the
|
|
26
|
+
* side lane, a throwing or over-deadline notifier is logged and
|
|
27
|
+
* isolated (the other notifiers still receive the event), and nothing
|
|
28
|
+
* is retried durably — reliability is a notifier's own concern. The
|
|
29
|
+
* hub listener itself never blocks (the hub contract): every delivery
|
|
30
|
+
* is kicked fire-and-forget onto the side lane.
|
|
31
|
+
*
|
|
32
|
+
* Transports live in extensions (ntfy, Signal, web push); core ships
|
|
33
|
+
* none — with zero notifier contributions this module subscribes
|
|
34
|
+
* nothing at all.
|
|
35
|
+
*/
|
|
36
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
37
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
38
|
+
import { SessionMetaDoc } from "./session-meta.js";
|
|
39
|
+
/** The conversation's display name (spec §9.6): name ?? autoName ?? fallback. */
|
|
40
|
+
function displayName(meta, id) {
|
|
41
|
+
return meta?.name ?? meta?.autoName ?? `Conversation ${id}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Start the notifications surface. Returns its unsubscribe (the server
|
|
45
|
+
* never calls it today — the surface rides the process lifetime — but
|
|
46
|
+
* tests and embedders may). A no-op unsubscribe when no extension
|
|
47
|
+
* contributed a notifier: nothing subscribes, zero overhead.
|
|
48
|
+
*/
|
|
49
|
+
export function startNotifications(ctx) {
|
|
50
|
+
const { opened, config } = ctx;
|
|
51
|
+
const targets = [];
|
|
52
|
+
for (const binding of opened.notifierBindings) {
|
|
53
|
+
// The Task 2 ext-route host semantics: the captured seam host,
|
|
54
|
+
// cast across the not-yet-bridged members.
|
|
55
|
+
const host = opened.seamHosts.get(binding.extension);
|
|
56
|
+
for (const notifier of binding.notifiers) {
|
|
57
|
+
targets.push({ extension: binding.extension, notifier, host });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (targets.length === 0)
|
|
61
|
+
return () => { };
|
|
62
|
+
const bus = opened.eventBus;
|
|
63
|
+
const watchers = ctx.watchersByConversation;
|
|
64
|
+
/** Deliver one occurrence to every recipient through every notifier. */
|
|
65
|
+
const deliver = (base, recipients) => {
|
|
66
|
+
// The deep link rides only when the deployment set a public URL and
|
|
67
|
+
// the event names a conversation.
|
|
68
|
+
const url = base.conversationId !== undefined && config.publicUrl !== undefined
|
|
69
|
+
? `${config.publicUrl}/#/c/${base.conversationId}`
|
|
70
|
+
: undefined;
|
|
71
|
+
for (const user of recipients) {
|
|
72
|
+
const event = {
|
|
73
|
+
type: base.type,
|
|
74
|
+
conversationId: base.conversationId,
|
|
75
|
+
jobId: base.jobId,
|
|
76
|
+
user,
|
|
77
|
+
title: base.title,
|
|
78
|
+
body: base.body,
|
|
79
|
+
...(url === undefined ? {} : { url }),
|
|
80
|
+
};
|
|
81
|
+
for (const target of targets) {
|
|
82
|
+
// Fire-and-forget per transport call: one notifier's failure or
|
|
83
|
+
// hang neither blocks nor loses the others' deliveries.
|
|
84
|
+
void bus.runBounded(`notifier ${target.extension}.${target.notifier.name}`, () => target.notifier.notify(event, {
|
|
85
|
+
host: target.host,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** One conversation's live SessionMeta (undefined on absence or read failure, logged). */
|
|
91
|
+
const metaOf = async (conversationId) => {
|
|
92
|
+
try {
|
|
93
|
+
// SAFETY: the host event's plain `number` is the branded
|
|
94
|
+
// ConversationId the snapshot takes; compared/used by value.
|
|
95
|
+
return await opened.harness.snapshot(SessionMetaDoc, conversationId, BACKGROUND_CONTEXT);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.error("notifications: session-meta read failed:", error);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
/** interaction.pending → the owner plus every directory user with write. */
|
|
103
|
+
const handleInteraction = async (conversationId) => {
|
|
104
|
+
const meta = await metaOf(conversationId);
|
|
105
|
+
const owner = meta?.owner;
|
|
106
|
+
const sharing = meta?.sharing;
|
|
107
|
+
// The owner first (always a recipient — even one the directory no
|
|
108
|
+
// longer lists); then every directory user whose accessOf is write.
|
|
109
|
+
const recipients = new Set([owner]);
|
|
110
|
+
for (const { user, admin } of opened.identity.users.list()) {
|
|
111
|
+
if (accessOf({ user, superuser: admin }, { owner, sharing }) === "write") {
|
|
112
|
+
recipients.add(user);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
deliver({
|
|
116
|
+
type: "interaction.pending",
|
|
117
|
+
conversationId,
|
|
118
|
+
jobId: undefined,
|
|
119
|
+
title: "Sigrun needs you",
|
|
120
|
+
body: displayName(meta, conversationId),
|
|
121
|
+
}, recipients);
|
|
122
|
+
};
|
|
123
|
+
/** run.settled → the owner, gated by the live `notifications.runSettled` mode. */
|
|
124
|
+
const handleRunSettled = async (conversationId) => {
|
|
125
|
+
const mode = opened.settings.read().notifications?.runSettled ?? "unwatched";
|
|
126
|
+
if (mode === "never")
|
|
127
|
+
return;
|
|
128
|
+
const meta = await metaOf(conversationId);
|
|
129
|
+
if (mode === "cron" && meta?.source !== "cron")
|
|
130
|
+
return;
|
|
131
|
+
if (mode === "unwatched" &&
|
|
132
|
+
(watchers.get(conversationId) ?? 0) > 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
deliver({
|
|
136
|
+
type: "run.settled",
|
|
137
|
+
conversationId,
|
|
138
|
+
jobId: undefined,
|
|
139
|
+
title: "Reply ready",
|
|
140
|
+
body: displayName(meta, conversationId),
|
|
141
|
+
}, [meta?.owner]);
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* job.changed → job.failed once per failed RUN. `job.changed` fires
|
|
145
|
+
* for every record change; the run identity (jobId + fire key +
|
|
146
|
+
* startedAt) dedupes — a later change re-serving the same failed
|
|
147
|
+
* `lastRun` never re-notifies, and the next run's settle does.
|
|
148
|
+
*/
|
|
149
|
+
const lastNotifiedFailedRun = new Map();
|
|
150
|
+
const handleJobChanged = (job) => {
|
|
151
|
+
const run = job.lastRun;
|
|
152
|
+
if (run?.outcome !== "failed")
|
|
153
|
+
return;
|
|
154
|
+
const identity = `${run.jobId}:${run.fireAt}:${run.manual}:${run.startedAt}`;
|
|
155
|
+
if (lastNotifiedFailedRun.get(job.id) === identity)
|
|
156
|
+
return;
|
|
157
|
+
lastNotifiedFailedRun.set(job.id, identity);
|
|
158
|
+
deliver({
|
|
159
|
+
type: "job.failed",
|
|
160
|
+
conversationId: run.conversationId,
|
|
161
|
+
jobId: job.id,
|
|
162
|
+
title: `Job ${job.name} failed`,
|
|
163
|
+
// The observer always settles a failed run with its error
|
|
164
|
+
// text; the fallback only covers a hand-corrupted record.
|
|
165
|
+
body: run.error ?? "the run failed",
|
|
166
|
+
}, [job.owner]);
|
|
167
|
+
};
|
|
168
|
+
const unsubscribe = opened.events.subscribe((event) => {
|
|
169
|
+
// The hub contract: no blocking work — each handling is kicked onto
|
|
170
|
+
// the bus's bounded side lane (resolution + delivery alike never
|
|
171
|
+
// run inline here).
|
|
172
|
+
if (event.type === "interaction.pending") {
|
|
173
|
+
void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId));
|
|
174
|
+
}
|
|
175
|
+
else if (event.type === "run.settled") {
|
|
176
|
+
void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
|
|
177
|
+
}
|
|
178
|
+
else if (event.type === "job.changed") {
|
|
179
|
+
void bus.runBounded("notifications job.changed", () => {
|
|
180
|
+
handleJobChanged(event.job);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
return unsubscribe;
|
|
185
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership transfer (core hardening W1 Task 4, spec §5.8 user ruling
|
|
3
|
+
* 9): the participant seam `DELETE /v1/users/:user?transferTo=` drives.
|
|
4
|
+
*
|
|
5
|
+
* Core owns ONE participant — the conversation rewrite (`scanConversations`
|
|
6
|
+
* → session-meta snapshot → one commit per `owner === from` hit). Anything
|
|
7
|
+
* else that records owners (W4's jobs, extensions' own records via the
|
|
8
|
+
* `userDeleted` lifecycle event) registers through
|
|
9
|
+
* {@link registerOwnershipParticipant} and runs in registration order.
|
|
10
|
+
*
|
|
11
|
+
* Participants are held per {@link ServerContext} in a module `WeakMap`:
|
|
12
|
+
* the context record stays the immutable boot artifact the routes
|
|
13
|
+
* receive, while the registry is server-instance state.
|
|
14
|
+
*/
|
|
15
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
16
|
+
import { SCAN_PAGE_SIZE } from "./helpers.js";
|
|
17
|
+
import { SessionMetaDoc } from "./session-meta.js";
|
|
18
|
+
/** The per-server participant lists (registration order = run order). */
|
|
19
|
+
const participants = new WeakMap();
|
|
20
|
+
/**
|
|
21
|
+
* Register one transfer participant on `ctx`. Callers register at boot or
|
|
22
|
+
* module-init time (W4's jobs module); the registry never reorders.
|
|
23
|
+
*/
|
|
24
|
+
export function registerOwnershipParticipant(ctx, p) {
|
|
25
|
+
const list = participants.get(ctx) ?? [];
|
|
26
|
+
list.push(p);
|
|
27
|
+
participants.set(ctx, list);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run every registered participant for the `from` → `to` transfer, in
|
|
31
|
+
* registration order. The core conversation participant is registered
|
|
32
|
+
* first (`createServer`), so conversations transfer before extensions
|
|
33
|
+
* observe `userDeleted`.
|
|
34
|
+
*/
|
|
35
|
+
export async function transferOwnership(ctx, from, to) {
|
|
36
|
+
for (const p of participants.get(ctx) ?? []) {
|
|
37
|
+
await p(from, to);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register the core conversation-ownership participant: the durable scan
|
|
42
|
+
* is authoritative (conversations other processes created all transfer),
|
|
43
|
+
* and each `owner === from` hit is ONE commit that rewrites the
|
|
44
|
+
* session-meta doc's owner — never a read-modify-write across commits.
|
|
45
|
+
*/
|
|
46
|
+
export function registerCoreOwnershipParticipant(ctx) {
|
|
47
|
+
registerOwnershipParticipant(ctx, async (from, to) => {
|
|
48
|
+
const { opened } = ctx;
|
|
49
|
+
let cursor;
|
|
50
|
+
do {
|
|
51
|
+
const page = await opened.storage.scanConversations({ order: "ascending" }, SCAN_PAGE_SIZE, cursor, BACKGROUND_CONTEXT);
|
|
52
|
+
for (const record of page.items) {
|
|
53
|
+
const conversation = await opened.harness.conversation(record.id, BACKGROUND_CONTEXT);
|
|
54
|
+
if (conversation === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
57
|
+
if (meta?.owner !== from)
|
|
58
|
+
continue;
|
|
59
|
+
await conversation.commit(async (tx) => {
|
|
60
|
+
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
61
|
+
draft.owner = to;
|
|
62
|
+
}, BACKGROUND_CONTEXT);
|
|
63
|
+
}
|
|
64
|
+
cursor = page.next;
|
|
65
|
+
} while (cursor !== undefined);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared readiness storage check (core-hardening Task 5): the ONE
|
|
3
|
+
* derivation behind BOTH `GET /readyz`'s storage verdict and `/metrics`'s
|
|
4
|
+
* `sigrun_ready` gauge, so the k8s probe and the alerting signal cannot
|
|
5
|
+
* disagree.
|
|
6
|
+
*
|
|
7
|
+
* The check is the exact read `/readyz` has always done (W2 §6.1,
|
|
8
|
+
* storage-only readiness): resolve the reserved root conversation
|
|
9
|
+
* through the session line (`storage.conversation()` under
|
|
10
|
+
* `harness.conversation`) — one real sqlite record fetch proving the db
|
|
11
|
+
* file + session answer right now. A closed harness throws here
|
|
12
|
+
* (assertOpen) — caught, reported as "failed"; `undefined` (record gone)
|
|
13
|
+
* likewise fails — not a harness anyone should route traffic to.
|
|
14
|
+
*
|
|
15
|
+
* Core-hardening's finding: `sigrun_up` reflects `harness.inspect()`,
|
|
16
|
+
* which can stay healthy while this read fails — then `/readyz`
|
|
17
|
+
* answered 503 but the `/metrics` scrape stayed green and nothing
|
|
18
|
+
* paged. The gauge answers the same call this function makes.
|
|
19
|
+
*/
|
|
20
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
21
|
+
/**
|
|
22
|
+
* The readiness storage check: `"ok"` when the root conversation
|
|
23
|
+
* resolves through the session line, `"failed"` otherwise (thrown or
|
|
24
|
+
* absent — either way, not a harness to route traffic to). Never throws.
|
|
25
|
+
*/
|
|
26
|
+
export async function checkStorage(ctx) {
|
|
27
|
+
try {
|
|
28
|
+
const conversation = await ctx.opened.harness.conversation(ctx.rootId, BACKGROUND_CONTEXT);
|
|
29
|
+
// undefined = storage answered but the root record is gone — not a
|
|
30
|
+
// harness anyone should route traffic to.
|
|
31
|
+
return conversation === undefined ? "failed" : "ok";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return "failed";
|
|
35
|
+
}
|
|
36
|
+
}
|