@sigrun/sigrun 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +214 -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 +89 -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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider surface routes: `GET /v1/models` (the merged chat-model
|
|
3
|
+
* catalog as picker entries — no provider internals: baseUrl, headers,
|
|
4
|
+
* and auth stay server-side) and `GET /v1/providers` (the secret-free
|
|
5
|
+
* per-provider readiness report, spec §6.4/§7.6).
|
|
6
|
+
*
|
|
7
|
+
* Core hardening W1 Task 6 (spec §5.7): the readiness report MOVED here
|
|
8
|
+
* from `/v1/auth/check` so `/v1/auth` is API auth only — the old path
|
|
9
|
+
* serves the identical body as a deprecated alias for one release,
|
|
10
|
+
* marking its responses with a `Deprecation: true` header.
|
|
11
|
+
*
|
|
12
|
+
* Core hardening W3 Task 6 (spec §7.6) adds the MUTATIONS: `POST
|
|
13
|
+
* /v1/providers/:id/refresh` (one bounded refresh, the entry back),
|
|
14
|
+
* `PUT /v1/providers/:id/credential {apiKey}` (admin) and `DELETE …
|
|
15
|
+
* /credential` (admin) — writes through `opened.auth` (the bridge stays
|
|
16
|
+
* read-only; the ROUTE is the writer), then ONE refresh, then the entry.
|
|
17
|
+
* Restart-free by the credential-precedence design: the auth bridge
|
|
18
|
+
* resolves auth.json per use, so the same in-process catalog picks the
|
|
19
|
+
* new key up on the next refresh — no provider restart, no re-boot.
|
|
20
|
+
*/
|
|
21
|
+
import { DEFAULT_AUTH_KEY } from "../../harness/auth.js";
|
|
22
|
+
import { providerAuthStatus } from "../helpers.js";
|
|
23
|
+
/**
|
|
24
|
+
* The per-provider readiness report (the shared derivation BOTH routes
|
|
25
|
+
* serve — one body, never two). The response carries ONLY a per-provider
|
|
26
|
+
* status (+ optional tracker detail) — no env values, no key material, no
|
|
27
|
+
* hints beyond the status itself (spec §6.4: "The API never accepts or
|
|
28
|
+
* returns credentials"). The status is a pure synchronous derivation over
|
|
29
|
+
* boot-retained state (same discipline as /v1/meta), per provider in
|
|
30
|
+
* catalog order (enumeration is over PROVIDERS — `getProviders()` — not
|
|
31
|
+
* models, so an empty-catalog boot contribution still appears):
|
|
32
|
+
*
|
|
33
|
+
* 1. readiness tracker registered by the owning contributor
|
|
34
|
+
* (`contribution.readiness`): reported verbatim — the three-state
|
|
35
|
+
* vocabulary `not_configured` (no credential resolved — an
|
|
36
|
+
* unconfigured optional plugin, not a degraded process) |
|
|
37
|
+
* `degraded` (configured, last refresh failed) | `ready` (refresh
|
|
38
|
+
* succeeded / static catalog), plus its optional `detail`.
|
|
39
|
+
* 2. contributor-owned WITHOUT a tracker: the legacy producer mapping
|
|
40
|
+
* is preserved — "ok" → `ready`, "degraded" → `invalid` (the §3
|
|
41
|
+
* union is exactly ok | degraded; "degraded" is the known-bad
|
|
42
|
+
* signal). A producer is per-contributor, not per-provider, so
|
|
43
|
+
* every provider a contributor owns shares its status. No producer
|
|
44
|
+
* → `not_ready` (nothing to verify).
|
|
45
|
+
* 3. env-degenerate provider (no contributor owns it — the collector's
|
|
46
|
+
* ownership map has no entry): the legacy env-presence mapping is
|
|
47
|
+
* preserved — `ready` iff a key resolves (`auth.json` `"default"`
|
|
48
|
+
* or the provider's own entry, or a non-empty `OPENAI_API_KEY` in
|
|
49
|
+
* the boot-retained provider env), else `not_ready`. (The
|
|
50
|
+
* degenerate provider reports §3 health "ok" by absence, so key
|
|
51
|
+
* presence is the honest readiness check for it; the three-state
|
|
52
|
+
* vocabulary applies to plugin trackers only.)
|
|
53
|
+
*/
|
|
54
|
+
/** The per-request index of `opened.providerHealth` producers by name. */
|
|
55
|
+
function producersIndex(opened) {
|
|
56
|
+
return new Map(opened.providerHealth.map((producer) => [producer.name, producer]));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One provider's report entry: the readiness derivation PLUS the W3
|
|
60
|
+
* Task 6 enrichment (spec §7.6) — the catalog `models` count
|
|
61
|
+
* (`models.getModels(id)`, pi-ai's best-effort count) and the owning
|
|
62
|
+
* contributor `owner` from the collector's ownership map (absent for
|
|
63
|
+
* the env-degenerate provider). The same entry serves every provider
|
|
64
|
+
* route: the report, the refresh response, and the credential
|
|
65
|
+
* write/delete responses — one shape, never two.
|
|
66
|
+
*/
|
|
67
|
+
function readinessEntry(opened, providerId, producersByName) {
|
|
68
|
+
const { status, detail } = providerAuthStatus(opened, providerId, producersByName);
|
|
69
|
+
const owner = opened.providerOwnership.get(providerId);
|
|
70
|
+
return {
|
|
71
|
+
provider: providerId,
|
|
72
|
+
status,
|
|
73
|
+
models: opened.models.getModels(providerId).length,
|
|
74
|
+
...(owner === undefined ? {} : { owner }),
|
|
75
|
+
...(detail === undefined ? {} : { detail }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function providerReadinessReport(opened) {
|
|
79
|
+
// W2 Task 8's reconciliation: the per-provider derivation lives in ONE
|
|
80
|
+
// place — `providerAuthStatus` (helpers.ts, shared with the
|
|
81
|
+
// `sigrun_provider_ready{provider,status}` metric) — so this report and
|
|
82
|
+
// the metrics exposition can never drift. The response shape is W1's
|
|
83
|
+
// `/v1/providers` + deprecated-`/v1/auth/check` body plus the W3 Task 6
|
|
84
|
+
// enrichment (`models`, `owner`).
|
|
85
|
+
const producersByName = producersIndex(opened);
|
|
86
|
+
const providers = [];
|
|
87
|
+
for (const { id: providerId } of opened.models.getProviders()) {
|
|
88
|
+
providers.push(readinessEntry(opened, providerId, producersByName));
|
|
89
|
+
}
|
|
90
|
+
return { providers };
|
|
91
|
+
}
|
|
92
|
+
/** Register the model-catalog and provider-readiness routes. */
|
|
93
|
+
export function registerProviderRoutes(app, ctx) {
|
|
94
|
+
const { opened } = ctx;
|
|
95
|
+
// The readiness home (spec §5.7/§7.6): the secret-free per-provider
|
|
96
|
+
// report, served from the one shared derivation above.
|
|
97
|
+
app.get("/v1/providers", async () => {
|
|
98
|
+
return providerReadinessReport(opened);
|
|
99
|
+
});
|
|
100
|
+
// The deprecated alias (spec §5.7: "The old path stays as a deprecated
|
|
101
|
+
// alias for one release"): the identical body plus the RFC 8594-style
|
|
102
|
+
// `Deprecation` header so clients can see the move on the wire.
|
|
103
|
+
app.get("/v1/auth/check", async (_request, reply) => {
|
|
104
|
+
reply.header("Deprecation", "true");
|
|
105
|
+
return providerReadinessReport(opened);
|
|
106
|
+
});
|
|
107
|
+
// The `:id` prelude shared by every mutation route: the provider must
|
|
108
|
+
// exist in the catalog (`getProviders()` is the enumeration the report
|
|
109
|
+
// serves, so the 404 vocabulary matches it). Key material never
|
|
110
|
+
// appears in any of these routes — statuses and counts only.
|
|
111
|
+
const isKnownProvider = (providerId) => opened.models.getProvider(providerId) !== undefined;
|
|
112
|
+
// One bounded refresh of the named provider (W3 Task 6, spec §7.6):
|
|
113
|
+
// the SAME policy the boot/interval/refresh-on-miss paths run — bounded
|
|
114
|
+
// by the deadline, failures warned (never the route's error), a
|
|
115
|
+
// flipped provider publishes `providers.changed`. Not admin-gated
|
|
116
|
+
// (spec §7.6 gates the credential writes only).
|
|
117
|
+
app.post("/v1/providers/:id/refresh", async (request, reply) => {
|
|
118
|
+
const { id } = request.params;
|
|
119
|
+
if (!isKnownProvider(id)) {
|
|
120
|
+
return await reply.code(404).send({ error: "provider_not_found" });
|
|
121
|
+
}
|
|
122
|
+
await opened.refreshProvider(id);
|
|
123
|
+
return readinessEntry(opened, id, producersIndex(opened));
|
|
124
|
+
});
|
|
125
|
+
// The auth.json key a credential write targets (spec §7.6): the magic
|
|
126
|
+
// `"default"` for the DEFAULT model's provider (the precedence fix
|
|
127
|
+
// makes that the winning key for exactly that provider), the
|
|
128
|
+
// provider's own id otherwise.
|
|
129
|
+
const credentialKey = (providerId) => providerId === opened.defaultModel.provider
|
|
130
|
+
? DEFAULT_AUTH_KEY
|
|
131
|
+
: providerId;
|
|
132
|
+
// Write the provider's credential (admin): store write → one refresh
|
|
133
|
+
// (the bridge resolves per use, so the new key is live with NO restart)
|
|
134
|
+
// → the entry. The body is `{apiKey: string}` — a non-empty string,
|
|
135
|
+
// nothing else (errors name the field, never the value).
|
|
136
|
+
app.put("/v1/providers/:id/credential", async (request, reply) => {
|
|
137
|
+
if (!request.principal.admin) {
|
|
138
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
139
|
+
}
|
|
140
|
+
const { id } = request.params;
|
|
141
|
+
if (!isKnownProvider(id)) {
|
|
142
|
+
return await reply.code(404).send({ error: "provider_not_found" });
|
|
143
|
+
}
|
|
144
|
+
const body = request.body;
|
|
145
|
+
if (typeof body !== "object" ||
|
|
146
|
+
body === null ||
|
|
147
|
+
Array.isArray(body)) {
|
|
148
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
149
|
+
}
|
|
150
|
+
const apiKey = body.apiKey;
|
|
151
|
+
if (typeof apiKey !== "string" || apiKey === "") {
|
|
152
|
+
return await reply
|
|
153
|
+
.code(400)
|
|
154
|
+
.send({ error: "invalid_field", field: "apiKey" });
|
|
155
|
+
}
|
|
156
|
+
await opened.auth.setApiKey(credentialKey(id), apiKey);
|
|
157
|
+
await opened.refreshProvider(id);
|
|
158
|
+
return readinessEntry(opened, id, producersIndex(opened));
|
|
159
|
+
});
|
|
160
|
+
// Remove the provider's credential (admin): store delete of that SAME
|
|
161
|
+
// key → one refresh → the entry. Restart-free the same way — the
|
|
162
|
+
// tracker re-seeds `not_configured` on the refresh's auth resolution.
|
|
163
|
+
app.delete("/v1/providers/:id/credential", async (request, reply) => {
|
|
164
|
+
if (!request.principal.admin) {
|
|
165
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
166
|
+
}
|
|
167
|
+
const { id } = request.params;
|
|
168
|
+
if (!isKnownProvider(id)) {
|
|
169
|
+
return await reply.code(404).send({ error: "provider_not_found" });
|
|
170
|
+
}
|
|
171
|
+
await opened.auth.deleteApiKey(credentialKey(id));
|
|
172
|
+
await opened.refreshProvider(id);
|
|
173
|
+
return readinessEntry(opened, id, producersIndex(opened));
|
|
174
|
+
});
|
|
175
|
+
// Models: the merged chat-model catalog as picker entries (no provider
|
|
176
|
+
// internals — baseUrl/headers/auth stay server-side).
|
|
177
|
+
app.get("/v1/models", async () => {
|
|
178
|
+
const models = opened.models.getModels().map((model) => ({
|
|
179
|
+
id: `${model.provider}/${model.id}`,
|
|
180
|
+
provider: model.provider,
|
|
181
|
+
modelId: model.id,
|
|
182
|
+
name: model.name,
|
|
183
|
+
contextWindow: model.contextWindow,
|
|
184
|
+
maxTokens: model.maxTokens,
|
|
185
|
+
reasoning: model.reasoning,
|
|
186
|
+
input: model.input,
|
|
187
|
+
// Project the four rate fields explicitly (ModelCostRates); `tiers` is
|
|
188
|
+
// dropped for v1 (the picker doesn't use them) — no unsound cast.
|
|
189
|
+
cost: {
|
|
190
|
+
input: model.cost.input,
|
|
191
|
+
output: model.cost.output,
|
|
192
|
+
cacheRead: model.cost.cacheRead,
|
|
193
|
+
cacheWrite: model.cost.cacheWrite,
|
|
194
|
+
},
|
|
195
|
+
}));
|
|
196
|
+
return { models };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings routes over the file-backed store on the harness's live
|
|
3
|
+
* settings (pi parity): GET serves the current effective settings
|
|
4
|
+
* (absent file = `{}` — every default); PATCH validates + merges +
|
|
5
|
+
* persists through the store.
|
|
6
|
+
*/
|
|
7
|
+
import { SettingsValidationError } from "../../harness/settings.js";
|
|
8
|
+
import { settingsResponse } from "../helpers.js";
|
|
9
|
+
/** Register `GET`/`PATCH /v1/settings`. */
|
|
10
|
+
export function registerSettingsRoutes(app, ctx) {
|
|
11
|
+
const { opened, config, env } = ctx;
|
|
12
|
+
// PATCH semantics: the harness picks a change up on the next resolution
|
|
13
|
+
// (steeringMode on the next submission's admission, because
|
|
14
|
+
// pi-durable re-reads `HarnessSettings` per boundary). Errors follow
|
|
15
|
+
// the repo's PATCH conventions: 400 `invalid_field` naming the key
|
|
16
|
+
// (unknown key, wrong type), 400 `invalid_body` for a non-object.
|
|
17
|
+
app.get("/v1/settings", async () => {
|
|
18
|
+
return settingsResponse(opened, config, env);
|
|
19
|
+
});
|
|
20
|
+
app.patch("/v1/settings", async (request, reply) => {
|
|
21
|
+
// Admin-only (spec §5.6): PATCH edits `extensions[]`, which is code
|
|
22
|
+
// execution at the next boot — a non-admin (any authenticated
|
|
23
|
+
// token-method user, a non-admin proxy user) gets 403 before the
|
|
24
|
+
// body is even read. GET stays ungated (spec lists only PATCH).
|
|
25
|
+
if (!request.principal.admin) {
|
|
26
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
27
|
+
}
|
|
28
|
+
const body = request.body;
|
|
29
|
+
if (body === undefined ||
|
|
30
|
+
body === null ||
|
|
31
|
+
typeof body !== "object" ||
|
|
32
|
+
Array.isArray(body)) {
|
|
33
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
opened.settings.update(body);
|
|
37
|
+
return settingsResponse(opened, config, env);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof SettingsValidationError) {
|
|
41
|
+
return await reply
|
|
42
|
+
.code(400)
|
|
43
|
+
.send({ error: "invalid_field", field: error.field });
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSE view stream route: `GET /v1/conversations/:id/stream`. The
|
|
3
|
+
* conversation resolves through the `loadConversation` pre-handler, so the
|
|
4
|
+
* unknown-id 404 lands as plain JSON BEFORE the handler hijacks the reply —
|
|
5
|
+
* a hijacked stream has no error event. This endpoint is exercised over a
|
|
6
|
+
* REAL socket in tests: it writes raw frames, which `app.inject()` cannot
|
|
7
|
+
* observe (light-my-request settles only on `end()`).
|
|
8
|
+
*/
|
|
9
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
10
|
+
import { loadConversation } from "../conversation-handler.js";
|
|
11
|
+
import { ID_PARAMS_SCHEMA } from "../helpers.js";
|
|
12
|
+
import { serializeConversationView, } from "../views.js";
|
|
13
|
+
import { diffViews, isEmptyDelta, } from "@sigrun/shared/view-delta";
|
|
14
|
+
/** Register the SSE view stream route. */
|
|
15
|
+
export function registerStreamRoutes(app, ctx) {
|
|
16
|
+
// SSE view stream (spec §5 "Views + watch frames over SSE", §7.3 ruling
|
|
17
|
+
// 11): one Server-Sent Events stream per conversation, hand-rolled over
|
|
18
|
+
// `reply.raw` (no @fastify/sse dependency). Event shape: `event: snapshot`
|
|
19
|
+
// with the wire view on connect, then per watch revision the diff against
|
|
20
|
+
// the last-sent view: a representable change rides as `event: delta`
|
|
21
|
+
// (entry append/remove + changed docs; empty deltas are skipped), a
|
|
22
|
+
// NON-representable one (a compaction's new head marker, any reorder)
|
|
23
|
+
// falls back to a fresh `event: snapshot` — so a reconnect's snapshot and
|
|
24
|
+
// the fallback are the same frame, and the client's rule stays
|
|
25
|
+
// "snapshot REPLACES, delta folds" (see the Appendix C
|
|
26
|
+
// named-verification record). `Last-Event-ID` is therefore accepted and
|
|
27
|
+
// deliberately ignored. Heartbeat: a `: ping` comment every 15s (proxy/
|
|
28
|
+
// LB idle-timeout survival). Lifecycle: exactly one owner (`finish` below)
|
|
29
|
+
// stops the watch and clears the interval on client disconnect or watch
|
|
30
|
+
// termination.
|
|
31
|
+
app.get("/v1/conversations/:id/stream", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request, reply) => {
|
|
32
|
+
const conversation = request.conversation;
|
|
33
|
+
// Acquire the watch BEFORE hijacking: an acquisition failure then
|
|
34
|
+
// lands on Fastify's fail-loud 500 path — a hijacked stream has no
|
|
35
|
+
// error event. `value` is the acquisition revision (the snapshot
|
|
36
|
+
// source); frames published between here and start() are held by the
|
|
37
|
+
// watch's bounded pending delivery, so nothing is lost.
|
|
38
|
+
const watch = await conversation.watch(BACKGROUND_CONTEXT);
|
|
39
|
+
// Watcher bookkeeping (W5 Task 6, spec §9.6): one attached client.
|
|
40
|
+
// The exactly-once `finish` below decrements — the notifications
|
|
41
|
+
// surface's `"unwatched"` runSettled rule reads this count.
|
|
42
|
+
const watchers = ctx.watchersByConversation;
|
|
43
|
+
watchers.set(conversation.id, (watchers.get(conversation.id) ?? 0) + 1);
|
|
44
|
+
reply.hijack();
|
|
45
|
+
const raw = reply.raw;
|
|
46
|
+
raw.writeHead(200, {
|
|
47
|
+
"content-type": "text/event-stream",
|
|
48
|
+
"cache-control": "no-cache",
|
|
49
|
+
"connection": "keep-alive",
|
|
50
|
+
"x-accel-buffering": "no",
|
|
51
|
+
});
|
|
52
|
+
raw.flushHeaders();
|
|
53
|
+
// Socket-level write errors after teardown have no consumer (the
|
|
54
|
+
// close path owns teardown); swallow them so a mid-write disconnect
|
|
55
|
+
// cannot crash the process with an unhandled 'error' event.
|
|
56
|
+
raw.on("error", () => { });
|
|
57
|
+
let nextId = 1;
|
|
58
|
+
const send = (event, payload) => {
|
|
59
|
+
// Single data line: JSON.stringify escapes newlines in strings.
|
|
60
|
+
raw.write(`id: ${nextId++}\nevent: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
61
|
+
};
|
|
62
|
+
let finished = false;
|
|
63
|
+
const heartbeat = setInterval(() => {
|
|
64
|
+
raw.write(": ping\n\n");
|
|
65
|
+
}, 15_000);
|
|
66
|
+
// The single cleanup owner: client disconnect (response close =
|
|
67
|
+
// socket termination — NOT `request.raw.on("close")`, which on
|
|
68
|
+
// Node 26 fires at request-message completion, i.e. immediately for
|
|
69
|
+
// a bodyless GET) or watch termination (`watch.closed`). The flag
|
|
70
|
+
// makes it exactly-once even though both paths can race.
|
|
71
|
+
const finish = (fromWatchEnd) => {
|
|
72
|
+
if (finished)
|
|
73
|
+
return;
|
|
74
|
+
finished = true;
|
|
75
|
+
clearInterval(heartbeat);
|
|
76
|
+
// Watcher bookkeeping (W5 Task 6): the paired decrement — exactly
|
|
77
|
+
// once per increment by the `finished` guard, floored at removal
|
|
78
|
+
// so a stray double path can never drive the count negative.
|
|
79
|
+
const remaining = (watchers.get(conversation.id) ?? 1) - 1;
|
|
80
|
+
if (remaining <= 0)
|
|
81
|
+
watchers.delete(conversation.id);
|
|
82
|
+
else
|
|
83
|
+
watchers.set(conversation.id, remaining);
|
|
84
|
+
if (!fromWatchEnd) {
|
|
85
|
+
// stop() is idempotent upstream; fire-and-forget because the
|
|
86
|
+
// stream is ending regardless and a stop() rejection after a
|
|
87
|
+
// dead socket has no consumer to fail loud to.
|
|
88
|
+
void watch.stop().catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
raw.end();
|
|
91
|
+
};
|
|
92
|
+
raw.on("close", () => finish(false));
|
|
93
|
+
// Upstream resolves `closed` with a WatchEnd for every terminal
|
|
94
|
+
// state (failures included); a rejection would be an upstream
|
|
95
|
+
// contract violation — catch it so one stream's anomaly ends that
|
|
96
|
+
// stream instead of taking the process down as an unhandled
|
|
97
|
+
// rejection.
|
|
98
|
+
void watch.closed.then(() => finish(true), () => finish(true));
|
|
99
|
+
// The client may have disconnected while the watch was being
|
|
100
|
+
// acquired (before the close listener existed): `destroyed` covers
|
|
101
|
+
// that window — stop the just-acquired watch instead of leaking it.
|
|
102
|
+
if (raw.destroyed) {
|
|
103
|
+
finish(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Snapshot FIRST (from the acquisition revision), then install the
|
|
107
|
+
// listener — pending frames flow after the snapshot because start
|
|
108
|
+
// never invokes the listener inline. `prev` is the last-sent wire
|
|
109
|
+
// view the diff loop measures against.
|
|
110
|
+
let prev = serializeConversationView(watch.value);
|
|
111
|
+
send("snapshot", prev);
|
|
112
|
+
watch.start(async (value) => {
|
|
113
|
+
if (finished)
|
|
114
|
+
return; // the stream is over; drop the frame
|
|
115
|
+
const next = serializeConversationView(value);
|
|
116
|
+
const delta = diffViews(prev, next);
|
|
117
|
+
prev = next;
|
|
118
|
+
// Not representable as entries append/remove + changed docs (a
|
|
119
|
+
// reorder or a compaction's new head marker) → a fresh snapshot.
|
|
120
|
+
if (delta === undefined) {
|
|
121
|
+
send("snapshot", next);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Nothing changed on the wire (a revision the serializer flattens
|
|
125
|
+
// away) — skip the frame entirely.
|
|
126
|
+
if (isEmptyDelta(delta))
|
|
127
|
+
return;
|
|
128
|
+
send("delta", delta);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|