@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -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`, and
202
- * the `effective` block (spec §6.6) — every server key's resolved value
203
- * and source, computed PER REQUEST over the fresh file read (a PATCH
204
- * lands as a `file` source on the next GET) and the boot env/config.
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, } from "@sigrun/shared/access";
20
- import { isSuperuser } from "./auth/principal.js";
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
- * The effective-admin projection of one directory user (gateway C8):
27
- * host-side callers (chat commands, no request headers) see the STORED
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.admin || record.groupAdmin === true,
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: effectiveAdminOf(record).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
+ }