@sigrun/sigrun 0.1.6 → 0.2.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 +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +259 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +213 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +152 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/notifications.js +21 -4
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +274 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +19 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
package/dist/server/helpers.js
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { isAbsolute, resolve } from "node:path";
|
|
10
10
|
import { effectiveServerSection, } from "../config.js";
|
|
11
|
+
import { pendingRestart } from "../settings/pending.js";
|
|
11
12
|
import { DEFAULT_AUTH_KEY } from "../harness/auth.js";
|
|
12
13
|
import { OPENAI_API_KEY_ENV } from "../harness/providers/index.js";
|
|
13
14
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
@@ -198,12 +199,15 @@ export const ID_PARAMS_SCHEMA = {
|
|
|
198
199
|
/** The settings response over the live store + registry snapshot. */
|
|
199
200
|
/**
|
|
200
201
|
* The `/v1/settings` payload (GET and the post-PATCH readback): the
|
|
201
|
-
* file's effective contents, the registry-computed `toolRoster`,
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
202
|
+
* file's effective contents, the registry-computed `toolRoster`, the
|
|
203
|
+
* `effective` block (spec §6.6), and the `pendingRestart` diff
|
|
204
|
+
* (UI-blocks U4 §13.2) — every server key's resolved value and source,
|
|
205
|
+
* computed PER REQUEST over the fresh file read (a PATCH lands as a
|
|
206
|
+
* `file` source on the next GET) and the boot env/config. A PATCH
|
|
207
|
+
* response additionally carries the write's own `applied`/`pending`
|
|
208
|
+
* classification (§13.1) via the optional `write` argument.
|
|
205
209
|
*/
|
|
206
|
-
export function settingsResponse(opened, config, env) {
|
|
210
|
+
export function settingsResponse(opened, config, env, write) {
|
|
207
211
|
const names = opened.registry
|
|
208
212
|
.snapshot()
|
|
209
213
|
.tools()
|
|
@@ -215,6 +219,8 @@ export function settingsResponse(opened, config, env) {
|
|
|
215
219
|
...file,
|
|
216
220
|
toolRoster: [...new Set(names)],
|
|
217
221
|
effective: effectiveServerSection(env, file.server, config),
|
|
222
|
+
pendingRestart: pendingRestart(file, opened.settingsBootSnapshot),
|
|
223
|
+
...(write === undefined ? {} : { applied: write.applied, pending: write.pending }),
|
|
218
224
|
};
|
|
219
225
|
}
|
|
220
226
|
/** Re-brand a route id as a ConversationId (the branded Id type is a number). */
|
|
@@ -241,12 +247,18 @@ export async function readView(conversation) {
|
|
|
241
247
|
* copied state (`fork: "current"`), so the caller's identity OVERWRITES
|
|
242
248
|
* the source's.
|
|
243
249
|
*/
|
|
244
|
-
export async function stampSessionOwner(conversation, owner) {
|
|
250
|
+
export async function stampSessionOwner(conversation, owner, opened) {
|
|
245
251
|
if (owner === undefined)
|
|
246
252
|
return;
|
|
253
|
+
await stampOwnerInCommit(conversation, owner, opened);
|
|
254
|
+
}
|
|
255
|
+
/** Stamp/replace an owner and rewrite per-user selection in ONE commit. */
|
|
256
|
+
export async function stampOwnerInCommit(conversation, owner, opened) {
|
|
247
257
|
await conversation.commit(async (tx) => {
|
|
248
258
|
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
259
|
+
const previous = draft.owner;
|
|
249
260
|
draft.owner = owner;
|
|
261
|
+
await opened.rewriteUserSelection(tx, conversation.id, previous, owner);
|
|
250
262
|
}, BACKGROUND_CONTEXT);
|
|
251
263
|
}
|
|
252
264
|
/**
|
|
@@ -16,25 +16,22 @@
|
|
|
16
16
|
* superuser's HTTP list does: every row visible, `access: "write"`.
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
|
-
import { accessOf as accessOfShared
|
|
20
|
-
import {
|
|
19
|
+
import { accessOf as accessOfShared } from "@sigrun/shared/access";
|
|
20
|
+
import { effectiveAdmin } from "../harness/identity/groups.js";
|
|
21
|
+
import { userWire } from "./routes/users.js";
|
|
22
|
+
import { accessPrincipalOf } from "./auth/access-principal.js";
|
|
21
23
|
import { abortOp, compactOp, configureOp, ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readFileOp, readMetaOp, submitOp, uploadOp, } from "./conversation-ops.js";
|
|
22
24
|
import { listModels } from "./helpers.js";
|
|
23
25
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
24
|
-
import { serializeConversationView } from "./views.js";
|
|
26
|
+
import { accountLinkWire, serializeConversationView } from "./views.js";
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* flag OR the persisted group grant, plus the raw `groupAdmin` as a
|
|
29
|
-
* definite boolean. The `/v1/users` wire keeps `admin` = the stored
|
|
30
|
-
* flag (the users-admin UI toggles that one); the two surfaces are
|
|
31
|
-
* deliberately different.
|
|
28
|
+
* Host-side callers see effective admin derived from stored groups;
|
|
29
|
+
* `groupAdmin` remains the derived compatibility field on the wire.
|
|
32
30
|
*/
|
|
33
|
-
function effectiveAdminOf(record) {
|
|
31
|
+
function effectiveAdminOf(record, adminGroups) {
|
|
34
32
|
return {
|
|
35
|
-
...record,
|
|
36
|
-
admin: record
|
|
37
|
-
groupAdmin: record.groupAdmin === true,
|
|
33
|
+
...userWire(record, adminGroups),
|
|
34
|
+
admin: effectiveAdmin(record, adminGroups),
|
|
38
35
|
};
|
|
39
36
|
}
|
|
40
37
|
/** Build the host bridge over one server context. */
|
|
@@ -136,6 +133,7 @@ export function createHostBridge(ctx) {
|
|
|
136
133
|
await configureOp(ctx, conversation, {
|
|
137
134
|
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
138
135
|
...(opts.thinking === undefined ? {} : { thinking: opts.thinking }),
|
|
136
|
+
...(opts.extensions === undefined ? {} : { extensions: opts.extensions }),
|
|
139
137
|
});
|
|
140
138
|
},
|
|
141
139
|
async compact(id) {
|
|
@@ -176,11 +174,36 @@ export function createHostBridge(ctx) {
|
|
|
176
174
|
// admin-gated /v1/users routes) — with EFFECTIVE admin (gateway
|
|
177
175
|
// C8): a persisted group grant counts for host-side checks.
|
|
178
176
|
async list() {
|
|
179
|
-
return opened.identity.users.list().map(effectiveAdminOf);
|
|
177
|
+
return opened.identity.users.list().map((record) => effectiveAdminOf(record, ctx.config.auth.adminGroups));
|
|
180
178
|
},
|
|
181
179
|
async get(user) {
|
|
182
180
|
const record = opened.identity.users.get(user);
|
|
183
|
-
return record === undefined ? undefined : effectiveAdminOf(record);
|
|
181
|
+
return record === undefined ? undefined : effectiveAdminOf(record, ctx.config.auth.adminGroups);
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
accounts: {
|
|
185
|
+
// The one link table (multi-user §11.3): core code over core's
|
|
186
|
+
// store — the single write path, with the store's own validation
|
|
187
|
+
// and conflict refusal. `account_link_conflict:` errors cross the
|
|
188
|
+
// bridge as plain Errors (extensions match the message prefix).
|
|
189
|
+
async lookup(provider, subject) {
|
|
190
|
+
const link = opened.identity.accounts.lookup(provider, subject);
|
|
191
|
+
return link === undefined ? undefined : accountLinkWire(link);
|
|
192
|
+
},
|
|
193
|
+
async link(provider, subject, user, via) {
|
|
194
|
+
const link = await opened.identity.accounts.link({
|
|
195
|
+
provider,
|
|
196
|
+
subject,
|
|
197
|
+
user,
|
|
198
|
+
via,
|
|
199
|
+
});
|
|
200
|
+
return accountLinkWire(link);
|
|
201
|
+
},
|
|
202
|
+
async unlink(provider, subject) {
|
|
203
|
+
return await opened.identity.accounts.unlink(provider, subject);
|
|
204
|
+
},
|
|
205
|
+
async listForUser(user) {
|
|
206
|
+
return opened.identity.accounts.listForUser(user).map(accountLinkWire);
|
|
184
207
|
},
|
|
185
208
|
},
|
|
186
209
|
access: {
|
|
@@ -190,10 +213,7 @@ export function createHostBridge(ctx) {
|
|
|
190
213
|
// directory admin is not a superuser for accessOf); the bare
|
|
191
214
|
// `{user}` shape is the shared rule's own principal.
|
|
192
215
|
const resolved = "admin" in principal
|
|
193
|
-
?
|
|
194
|
-
user: principal.user,
|
|
195
|
-
superuser: isSuperuser(principal),
|
|
196
|
-
}
|
|
216
|
+
? accessPrincipalOf(principal, opened.identity.users)
|
|
197
217
|
: { user: principal.user };
|
|
198
218
|
return accessOfShared(resolved, resource);
|
|
199
219
|
},
|
|
@@ -241,7 +261,7 @@ export function createHostBridge(ctx) {
|
|
|
241
261
|
user: record.user,
|
|
242
262
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
243
263
|
...(record.email === undefined ? {} : { email: record.email }),
|
|
244
|
-
admin:
|
|
264
|
+
admin: effectiveAdmin(record, ctx.config.auth.adminGroups),
|
|
245
265
|
service: false,
|
|
246
266
|
method: "host",
|
|
247
267
|
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image proxy's on-disk LRU cache (UI-blocks U7 Task 3, spec §11.2):
|
|
3
|
+
* entries live under `<cacheDir>/images/` (pod-local and outside backups,
|
|
4
|
+
* like the other cache roots), keyed by `sha256(u)` plus the `as`
|
|
5
|
+
* variant — NEVER the signed URL, whose signature changes on every
|
|
6
|
+
* serve. Capacity is 256 MiB by default; eviction is least-recently-hit
|
|
7
|
+
* first. Failures are never cached (callers `put` successful fetches
|
|
8
|
+
* only).
|
|
9
|
+
*
|
|
10
|
+
* Layout: each entry is one file `<sha256(u)>.<variant>` beside ONE
|
|
11
|
+
* `index.json` (the metadata table: type, size, added-at, clamped
|
|
12
|
+
* max-age, last-hit, pin). The index is written atomically on every mutation, so a re-open
|
|
13
|
+
* (the same cache dir, a new process) serves the prior entries.
|
|
14
|
+
*
|
|
15
|
+
* Eviction never deletes a file currently being STREAMED or WRITTEN
|
|
16
|
+
* (Review Focus 5): a `get` hit pins its entry until the returned
|
|
17
|
+
* stream closes (or the caller's response finishes), and a `put` holds
|
|
18
|
+
* its pin for the write's duration; the eviction pass skips pinned
|
|
19
|
+
* entries and takes the next-oldest instead.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
22
|
+
import { createReadStream } from "node:fs";
|
|
23
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
/** The images subdirectory under the cache root. */
|
|
26
|
+
export const IMAGES_CACHE_DIR = "images";
|
|
27
|
+
/** The default capacity: 256 MiB (spec §11.2). */
|
|
28
|
+
export const CACHE_CAPACITY_BYTES = 256 * 1024 * 1024;
|
|
29
|
+
/** The sha256-of-url + variant entry key (never the signed URL). */
|
|
30
|
+
function entryKey(u, variant) {
|
|
31
|
+
return `${createHash("sha256").update(u, "utf8").digest("hex")}.${variant}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The `<cacheDir>/images/` LRU cache. Construct with the CACHE ROOT (the
|
|
35
|
+
* `images/` child is created lazily on the first put / at open when the
|
|
36
|
+
* index exists). `capacityBytes` is test-overridable; the default is the
|
|
37
|
+
* spec's 256 MiB.
|
|
38
|
+
*/
|
|
39
|
+
export class ImageCache {
|
|
40
|
+
dir;
|
|
41
|
+
indexPath;
|
|
42
|
+
capacityBytes;
|
|
43
|
+
entries = new Map();
|
|
44
|
+
opened = false;
|
|
45
|
+
closed = false;
|
|
46
|
+
constructor(cacheRoot, options) {
|
|
47
|
+
this.dir = join(cacheRoot, IMAGES_CACHE_DIR);
|
|
48
|
+
this.indexPath = join(this.dir, "index.json");
|
|
49
|
+
this.capacityBytes = options?.capacityBytes ?? CACHE_CAPACITY_BYTES;
|
|
50
|
+
}
|
|
51
|
+
/** Open: load the index (creating the dir lazily). Idempotent. */
|
|
52
|
+
async open() {
|
|
53
|
+
if (this.opened)
|
|
54
|
+
return;
|
|
55
|
+
this.opened = true;
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(this.indexPath, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
if (cause.code === "ENOENT")
|
|
62
|
+
return;
|
|
63
|
+
throw cause;
|
|
64
|
+
}
|
|
65
|
+
// A corrupt index is rebuilt EMPTY (the files on disk are then
|
|
66
|
+
// orphaned until their names recur; a fresh fetch rewrites them).
|
|
67
|
+
// The cache is a cache: losing it is never an error worth
|
|
68
|
+
// refusing to boot over.
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const [key, entry] of Object.entries(parsed.entries)) {
|
|
77
|
+
// Pre-max-age indexes used the original default: one hour.
|
|
78
|
+
this.entries.set(key, { ...entry, maxAge: entry.maxAge ?? 3600, pins: 0 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** One hit, or `undefined` on a miss. `now` stamps the LRU order. */
|
|
82
|
+
async get(u, variant, now) {
|
|
83
|
+
await this.open();
|
|
84
|
+
const key = entryKey(u, variant);
|
|
85
|
+
const entry = this.entries.get(key);
|
|
86
|
+
if (entry === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const file = join(this.dir, key);
|
|
89
|
+
try {
|
|
90
|
+
await stat(file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// The index names a file that isn't there (a hand-cleaned dir):
|
|
94
|
+
// drop the row and miss.
|
|
95
|
+
this.entries.delete(key);
|
|
96
|
+
await this.persist();
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
entry.lastHit = now ?? Date.now();
|
|
100
|
+
entry.pins += 1;
|
|
101
|
+
await this.persist();
|
|
102
|
+
const stream = createReadStream(file);
|
|
103
|
+
let unpinned = false;
|
|
104
|
+
const unpin = () => {
|
|
105
|
+
// Node emits close after error: one hit releases one pin.
|
|
106
|
+
if (unpinned)
|
|
107
|
+
return;
|
|
108
|
+
unpinned = true;
|
|
109
|
+
entry.pins -= 1;
|
|
110
|
+
};
|
|
111
|
+
stream.once("close", unpin);
|
|
112
|
+
stream.once("error", unpin);
|
|
113
|
+
return { type: entry.type, addedAt: entry.addedAt, maxAge: entry.maxAge, stream };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Store one fetched image. The write is staged and renamed (atomic:
|
|
117
|
+
* a concurrent `get` never reads a half-written file) and the entry
|
|
118
|
+
* is pinned for the write's duration — the eviction pass a same-key
|
|
119
|
+
* overwrite triggers can never remove the file mid-rename.
|
|
120
|
+
*/
|
|
121
|
+
async put(u, variant, type, body, now, maxAge = 3600) {
|
|
122
|
+
await this.open();
|
|
123
|
+
if (this.closed)
|
|
124
|
+
return;
|
|
125
|
+
const key = entryKey(u, variant);
|
|
126
|
+
const at = now ?? Date.now();
|
|
127
|
+
const entry = {
|
|
128
|
+
type,
|
|
129
|
+
size: body.byteLength,
|
|
130
|
+
addedAt: at,
|
|
131
|
+
maxAge,
|
|
132
|
+
lastHit: at,
|
|
133
|
+
pins: 1,
|
|
134
|
+
};
|
|
135
|
+
this.entries.set(key, entry);
|
|
136
|
+
await mkdir(this.dir, { recursive: true });
|
|
137
|
+
const file = join(this.dir, key);
|
|
138
|
+
const staged = `${file}.${randomUUID()}.tmp`;
|
|
139
|
+
try {
|
|
140
|
+
await writeFile(staged, body, { mode: 0o644 });
|
|
141
|
+
await rename(staged, file);
|
|
142
|
+
}
|
|
143
|
+
catch (cause) {
|
|
144
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
145
|
+
this.entries.delete(key);
|
|
146
|
+
throw cause;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
entry.pins -= 1;
|
|
150
|
+
}
|
|
151
|
+
await this.evict();
|
|
152
|
+
await this.persist();
|
|
153
|
+
}
|
|
154
|
+
/** Evict least-recently-hit entries until total size fits capacity. */
|
|
155
|
+
async evict() {
|
|
156
|
+
let total = 0;
|
|
157
|
+
for (const entry of this.entries.values())
|
|
158
|
+
total += entry.size;
|
|
159
|
+
if (total <= this.capacityBytes)
|
|
160
|
+
return;
|
|
161
|
+
const oldestFirst = [...this.entries.entries()].sort((a, b) => a[1].lastHit - b[1].lastHit || a[1].addedAt - b[1].addedAt);
|
|
162
|
+
for (const [key, entry] of oldestFirst) {
|
|
163
|
+
if (total <= this.capacityBytes)
|
|
164
|
+
break;
|
|
165
|
+
// Never delete a file a response is streaming or a put is
|
|
166
|
+
// writing (Review Focus 5): skip it and take the next-oldest.
|
|
167
|
+
if (entry.pins > 0)
|
|
168
|
+
continue;
|
|
169
|
+
this.entries.delete(key);
|
|
170
|
+
total -= entry.size;
|
|
171
|
+
await rm(join(this.dir, key), { force: true }).catch(() => { });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Write the index (staged + renamed; the stores' discipline). */
|
|
175
|
+
async persist() {
|
|
176
|
+
if (this.closed)
|
|
177
|
+
return;
|
|
178
|
+
if (this.entries.size === 0) {
|
|
179
|
+
// An empty table is written too — a re-open then starts empty
|
|
180
|
+
// instead of resurrecting rows whose files are gone.
|
|
181
|
+
}
|
|
182
|
+
await mkdir(this.dir, { recursive: true });
|
|
183
|
+
const file = {
|
|
184
|
+
entries: Object.fromEntries([...this.entries.entries()].map(([key, entry]) => [
|
|
185
|
+
key,
|
|
186
|
+
{
|
|
187
|
+
type: entry.type,
|
|
188
|
+
size: entry.size,
|
|
189
|
+
addedAt: entry.addedAt,
|
|
190
|
+
maxAge: entry.maxAge,
|
|
191
|
+
lastHit: entry.lastHit,
|
|
192
|
+
},
|
|
193
|
+
])),
|
|
194
|
+
};
|
|
195
|
+
const staged = `${this.indexPath}.${randomUUID()}.tmp`;
|
|
196
|
+
try {
|
|
197
|
+
await writeFile(staged, `${JSON.stringify(file)}\n`, { mode: 0o644 });
|
|
198
|
+
await chmod(staged, 0o644);
|
|
199
|
+
await rename(staged, this.indexPath);
|
|
200
|
+
}
|
|
201
|
+
catch (cause) {
|
|
202
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
203
|
+
throw cause;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Flush the index; further `put`/`persist` calls no-op. */
|
|
207
|
+
async close() {
|
|
208
|
+
if (this.closed)
|
|
209
|
+
return;
|
|
210
|
+
await this.persist();
|
|
211
|
+
this.closed = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG conversion for the image proxy (UI-blocks U7 Task 3, spec §11.2
|
|
3
|
+
* ruling 17): with `as=png`, the server converts the fetched (or
|
|
4
|
+
* source-form) image to PNG with `sharp` (a server dependency) and the
|
|
5
|
+
* route caches the result beside the original variant. The TUI asks for
|
|
6
|
+
* it (pi-tui draws only PNG on Kitty without a registered transcoder);
|
|
7
|
+
* the webui doesn't.
|
|
8
|
+
*/
|
|
9
|
+
import sharp from "sharp";
|
|
10
|
+
/** Convert image bytes (PNG/JPEG/GIF/WebP) to PNG bytes. */
|
|
11
|
+
export async function toPng(bytes) {
|
|
12
|
+
return await sharp(bytes).png().toBuffer();
|
|
13
|
+
}
|