@songsid/agend 2.3.2-beta.1 → 2.3.2-beta.11

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 (298) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/dist/agent-cli.js +69 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +11 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-whats-new.js +3 -0
  7. package/dist/agent-whats-new.js.map +1 -1
  8. package/dist/antigravity-usage-scan.d.ts +58 -0
  9. package/dist/antigravity-usage-scan.js +257 -0
  10. package/dist/antigravity-usage-scan.js.map +1 -0
  11. package/dist/antigravity-usage-worker.d.ts +1 -0
  12. package/dist/antigravity-usage-worker.js +10 -0
  13. package/dist/antigravity-usage-worker.js.map +1 -0
  14. package/dist/auth-api.js +4 -0
  15. package/dist/auth-api.js.map +1 -1
  16. package/dist/backend/kiro-classic-archive.d.ts +52 -0
  17. package/dist/backend/kiro-classic-archive.js +444 -0
  18. package/dist/backend/kiro-classic-archive.js.map +1 -0
  19. package/dist/backend/kiro-identity.d.ts +83 -0
  20. package/dist/backend/kiro-identity.js +144 -0
  21. package/dist/backend/kiro-identity.js.map +1 -1
  22. package/dist/backend/kiro-migration-verify.d.ts +10 -0
  23. package/dist/backend/kiro-migration-verify.js +52 -0
  24. package/dist/backend/kiro-migration-verify.js.map +1 -0
  25. package/dist/backend/kiro-retired-model.d.ts +62 -0
  26. package/dist/backend/kiro-retired-model.js +160 -0
  27. package/dist/backend/kiro-retired-model.js.map +1 -0
  28. package/dist/backend/kiro-v2-store-worker.js +21 -1
  29. package/dist/backend/kiro-v2-store-worker.js.map +1 -1
  30. package/dist/backend/kiro-v2-store.d.ts +8 -0
  31. package/dist/backend/kiro-v2-store.js.map +1 -1
  32. package/dist/backend/kiro.d.ts +169 -2
  33. package/dist/backend/kiro.js +703 -28
  34. package/dist/backend/kiro.js.map +1 -1
  35. package/dist/backend/types.d.ts +108 -0
  36. package/dist/backend/types.js +9 -0
  37. package/dist/backend/types.js.map +1 -1
  38. package/dist/backend-signin-guide.d.ts +1 -0
  39. package/dist/backend-signin-guide.js +9 -0
  40. package/dist/backend-signin-guide.js.map +1 -0
  41. package/dist/backend-signin-pending.d.ts +10 -0
  42. package/dist/backend-signin-pending.js +44 -0
  43. package/dist/backend-signin-pending.js.map +1 -0
  44. package/dist/browser-backend-signins.d.ts +56 -0
  45. package/dist/browser-backend-signins.js +189 -0
  46. package/dist/browser-backend-signins.js.map +1 -0
  47. package/dist/cache-ledger.d.ts +38 -2
  48. package/dist/cache-ledger.js +133 -8
  49. package/dist/cache-ledger.js.map +1 -1
  50. package/dist/cache-prices.d.ts +2 -1
  51. package/dist/cache-prices.js +5 -2
  52. package/dist/cache-prices.js.map +1 -1
  53. package/dist/cache-service.d.ts +68 -0
  54. package/dist/cache-service.js +355 -13
  55. package/dist/cache-service.js.map +1 -1
  56. package/dist/channel/adapters/discord.d.ts +11 -0
  57. package/dist/channel/adapters/discord.js +71 -14
  58. package/dist/channel/adapters/discord.js.map +1 -1
  59. package/dist/channel/ipc-timeouts.js +3 -0
  60. package/dist/channel/ipc-timeouts.js.map +1 -1
  61. package/dist/channel/mcp-tools.js +41 -2
  62. package/dist/channel/mcp-tools.js.map +1 -1
  63. package/dist/channel/tool-router.js +2 -1
  64. package/dist/channel/tool-router.js.map +1 -1
  65. package/dist/classic-channel-manager.d.ts +11 -0
  66. package/dist/classic-channel-manager.js +39 -1
  67. package/dist/classic-channel-manager.js.map +1 -1
  68. package/dist/cli.js +54 -2
  69. package/dist/cli.js.map +1 -1
  70. package/dist/config-validator.d.ts +7 -1
  71. package/dist/config-validator.js +55 -10
  72. package/dist/config-validator.js.map +1 -1
  73. package/dist/config.d.ts +19 -0
  74. package/dist/config.js +33 -1
  75. package/dist/config.js.map +1 -1
  76. package/dist/cpu-quota.d.ts +23 -0
  77. package/dist/cpu-quota.js +86 -0
  78. package/dist/cpu-quota.js.map +1 -0
  79. package/dist/daemon.d.ts +68 -0
  80. package/dist/daemon.js +448 -26
  81. package/dist/daemon.js.map +1 -1
  82. package/dist/dashboard-permissions.d.ts +31 -6
  83. package/dist/dashboard-permissions.js +84 -9
  84. package/dist/dashboard-permissions.js.map +1 -1
  85. package/dist/doctor.js +2 -1
  86. package/dist/doctor.js.map +1 -1
  87. package/dist/fleet-control-audit.d.ts +1 -1
  88. package/dist/fleet-control-audit.js +3 -0
  89. package/dist/fleet-control-audit.js.map +1 -1
  90. package/dist/fleet-manager.d.ts +133 -2
  91. package/dist/fleet-manager.js +905 -94
  92. package/dist/fleet-manager.js.map +1 -1
  93. package/dist/forged-envelope-scan.d.ts +9 -0
  94. package/dist/forged-envelope-scan.js +30 -5
  95. package/dist/forged-envelope-scan.js.map +1 -1
  96. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
  97. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +24 -1
  98. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +49 -1
  99. package/dist/general-knowledge/skills/web-appearance/SKILL.md +49 -1
  100. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +47 -9
  101. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +54 -11
  102. package/dist/instance-config-impact.js +10 -5
  103. package/dist/instance-config-impact.js.map +1 -1
  104. package/dist/instance-lifecycle.d.ts +23 -0
  105. package/dist/instance-lifecycle.js +62 -12
  106. package/dist/instance-lifecycle.js.map +1 -1
  107. package/dist/kiro-db-reader.d.ts +5 -0
  108. package/dist/kiro-db-reader.js +18 -0
  109. package/dist/kiro-db-reader.js.map +1 -1
  110. package/dist/kiro-engine-status.d.ts +2 -0
  111. package/dist/kiro-engine-status.js +9 -0
  112. package/dist/kiro-engine-status.js.map +1 -1
  113. package/dist/kiro-migrate.d.ts +60 -0
  114. package/dist/kiro-migrate.js +160 -0
  115. package/dist/kiro-migrate.js.map +1 -0
  116. package/dist/kiro-session-proof.d.ts +41 -0
  117. package/dist/kiro-session-proof.js +89 -0
  118. package/dist/kiro-session-proof.js.map +1 -0
  119. package/dist/kiro-transcript-lane.d.ts +2 -0
  120. package/dist/kiro-transcript-lane.js.map +1 -1
  121. package/dist/kiro-transcript-worker.js +1 -0
  122. package/dist/kiro-transcript-worker.js.map +1 -1
  123. package/dist/kiro-tui-forensic.d.ts +12 -0
  124. package/dist/kiro-tui-forensic.js +110 -0
  125. package/dist/kiro-tui-forensic.js.map +1 -0
  126. package/dist/kiro-usage-run.d.ts +7 -0
  127. package/dist/kiro-usage-run.js +39 -0
  128. package/dist/kiro-usage-run.js.map +1 -0
  129. package/dist/kiro-usage-scan.d.ts +49 -0
  130. package/dist/kiro-usage-scan.js +223 -0
  131. package/dist/kiro-usage-scan.js.map +1 -0
  132. package/dist/kiro-usage-worker.d.ts +1 -0
  133. package/dist/kiro-usage-worker.js +10 -0
  134. package/dist/kiro-usage-worker.js.map +1 -0
  135. package/dist/kiro-usage.d.ts +58 -0
  136. package/dist/kiro-usage.js +310 -0
  137. package/dist/kiro-usage.js.map +1 -0
  138. package/dist/locale.js +230 -8
  139. package/dist/locale.js.map +1 -1
  140. package/dist/log-service.js +1 -1
  141. package/dist/log-service.js.map +1 -1
  142. package/dist/login-url.d.ts +4 -0
  143. package/dist/login-url.js +22 -0
  144. package/dist/login-url.js.map +1 -0
  145. package/dist/needs-you-hub.d.ts +2 -1
  146. package/dist/needs-you-hub.js +1 -1
  147. package/dist/needs-you-hub.js.map +1 -1
  148. package/dist/needs-you.d.ts +10 -2
  149. package/dist/needs-you.js +10 -2
  150. package/dist/needs-you.js.map +1 -1
  151. package/dist/notice-delivery.d.ts +16 -0
  152. package/dist/notice-delivery.js +35 -0
  153. package/dist/notice-delivery.js.map +1 -0
  154. package/dist/office-share-page.d.ts +16 -0
  155. package/dist/office-share-page.js +85 -0
  156. package/dist/office-share-page.js.map +1 -0
  157. package/dist/office-share-service.d.ts +91 -0
  158. package/dist/office-share-service.js +176 -0
  159. package/dist/office-share-service.js.map +1 -0
  160. package/dist/office-share.d.ts +268 -0
  161. package/dist/office-share.js +574 -0
  162. package/dist/office-share.js.map +1 -0
  163. package/dist/opencode-usage-scan.d.ts +28 -0
  164. package/dist/opencode-usage-scan.js +92 -0
  165. package/dist/opencode-usage-scan.js.map +1 -0
  166. package/dist/opencode-usage-worker.d.ts +1 -0
  167. package/dist/opencode-usage-worker.js +10 -0
  168. package/dist/opencode-usage-worker.js.map +1 -0
  169. package/dist/outbound-handlers.js +22 -2
  170. package/dist/outbound-handlers.js.map +1 -1
  171. package/dist/outbound-schemas.d.ts +65 -2
  172. package/dist/outbound-schemas.js +56 -2
  173. package/dist/outbound-schemas.js.map +1 -1
  174. package/dist/public-web-gateway.js +7 -1
  175. package/dist/public-web-gateway.js.map +1 -1
  176. package/dist/quickstart-api.js +13 -2
  177. package/dist/quickstart-api.js.map +1 -1
  178. package/dist/quickstart.js +2 -1
  179. package/dist/quickstart.js.map +1 -1
  180. package/dist/remote-card-answer.d.ts +83 -0
  181. package/dist/remote-card-answer.js +208 -0
  182. package/dist/remote-card-answer.js.map +1 -0
  183. package/dist/remote-card-prompt.d.ts +38 -0
  184. package/dist/remote-card-prompt.js +86 -0
  185. package/dist/remote-card-prompt.js.map +1 -0
  186. package/dist/reply-triggers.d.ts +2 -0
  187. package/dist/reply-triggers.js +3 -0
  188. package/dist/reply-triggers.js.map +1 -0
  189. package/dist/settings-api.d.ts +2 -0
  190. package/dist/settings-api.js +41 -16
  191. package/dist/settings-api.js.map +1 -1
  192. package/dist/settings-change.js +20 -14
  193. package/dist/settings-change.js.map +1 -1
  194. package/dist/settings-effect.js +7 -1
  195. package/dist/settings-effect.js.map +1 -1
  196. package/dist/settings-instance-patch.js +4 -1
  197. package/dist/settings-instance-patch.js.map +1 -1
  198. package/dist/setup-form.d.ts +1 -1
  199. package/dist/setup-form.js +55 -6
  200. package/dist/setup-form.js.map +1 -1
  201. package/dist/setup-host.d.ts +9 -0
  202. package/dist/setup-host.js +127 -6
  203. package/dist/setup-host.js.map +1 -1
  204. package/dist/setup-signin-assets.d.ts +2 -0
  205. package/dist/setup-signin-assets.js +6 -0
  206. package/dist/setup-signin-assets.js.map +1 -0
  207. package/dist/setup-wizard.js +2 -0
  208. package/dist/setup-wizard.js.map +1 -1
  209. package/dist/structured-signin-transport.d.ts +8 -0
  210. package/dist/structured-signin-transport.js +79 -0
  211. package/dist/structured-signin-transport.js.map +1 -0
  212. package/dist/structured-signin.d.ts +131 -0
  213. package/dist/structured-signin.js +388 -0
  214. package/dist/structured-signin.js.map +1 -0
  215. package/dist/tool-permissions.js +29 -0
  216. package/dist/tool-permissions.js.map +1 -1
  217. package/dist/topic-commands.js +6 -2
  218. package/dist/topic-commands.js.map +1 -1
  219. package/dist/transcript-sources.d.ts +27 -2
  220. package/dist/transcript-sources.js +104 -40
  221. package/dist/transcript-sources.js.map +1 -1
  222. package/dist/turn-reply-guard.d.ts +1 -1
  223. package/dist/turn-reply-guard.js +1 -1
  224. package/dist/turn-reply-guard.js.map +1 -1
  225. package/dist/types.d.ts +33 -8
  226. package/dist/ui/backend-signin.js +63 -10
  227. package/dist/ui/chat-store.js +3 -2
  228. package/dist/ui/chat-strings.js +4 -4
  229. package/dist/ui/fleet-office-editor.js +68 -11
  230. package/dist/ui/fleet-office-layout.js +110 -35
  231. package/dist/ui/fleet-office-model.js +6 -3
  232. package/dist/ui/fleet-office-overview.js +167 -0
  233. package/dist/ui/fleet-office-pack.d.ts +13 -3
  234. package/dist/ui/fleet-office-pack.js +214 -24
  235. package/dist/ui/fleet-office-skin.js +98 -23
  236. package/dist/ui/fleet-office-store.js +82 -5
  237. package/dist/ui/fleet-office-view.js +5 -4
  238. package/dist/ui/fleet-office-zones.js +232 -21
  239. package/dist/ui/fleet-office.js +549 -186
  240. package/dist/ui/fleet-usage.js +19 -15
  241. package/dist/ui/office-share-dialog.js +145 -0
  242. package/dist/ui/office-share-page.js +164 -0
  243. package/dist/ui/panel-chat.js +22 -3
  244. package/dist/ui/panel-log.js +4 -4
  245. package/dist/ui/panel-needs.js +5 -5
  246. package/dist/ui/panel-settings.js +38 -12
  247. package/dist/ui/settings-dialogs.js +8 -1
  248. package/dist/ui/settings-guests.js +17 -7
  249. package/dist/ui/settings-model.js +22 -6
  250. package/dist/ui/settings-registry.js +4 -0
  251. package/dist/ui/settings-strings.js +18 -2
  252. package/dist/ui/shared/app-i18n.js +4 -2
  253. package/dist/ui/shared/app-shell.js +14 -6
  254. package/dist/ui/shared/app-stream.js +2 -1
  255. package/dist/ui/shared/app.css +85 -4
  256. package/dist/ui/shared/office/cozy.png +0 -0
  257. package/dist/ui/shared/side-panel.js +95 -0
  258. package/dist/ui/structured-signin.js +115 -0
  259. package/dist/usage-report.d.ts +20 -0
  260. package/dist/usage-report.js +54 -17
  261. package/dist/usage-report.js.map +1 -1
  262. package/dist/usage-rollup.d.ts +9 -1
  263. package/dist/usage-rollup.js +20 -4
  264. package/dist/usage-rollup.js.map +1 -1
  265. package/dist/web-api.d.ts +23 -1
  266. package/dist/web-api.js +292 -3
  267. package/dist/web-api.js.map +1 -1
  268. package/dist/web-auth.js +1 -1
  269. package/dist/web-auth.js.map +1 -1
  270. package/dist/web-backend-login.d.ts +1 -7
  271. package/dist/web-backend-login.js +2 -24
  272. package/dist/web-backend-login.js.map +1 -1
  273. package/dist/web-office-acts.d.ts +173 -0
  274. package/dist/web-office-acts.js +285 -0
  275. package/dist/web-office-acts.js.map +1 -0
  276. package/dist/web-office-pack-read.d.ts +35 -0
  277. package/dist/web-office-pack-read.js +252 -0
  278. package/dist/web-office-pack-read.js.map +1 -0
  279. package/dist/web-office-pack-tools.d.ts +232 -0
  280. package/dist/web-office-pack-tools.js +754 -0
  281. package/dist/web-office-pack-tools.js.map +1 -0
  282. package/dist/web-office-skins.d.ts +71 -6
  283. package/dist/web-office-skins.js +321 -35
  284. package/dist/web-office-skins.js.map +1 -1
  285. package/dist/web-office-store.d.ts +5 -2
  286. package/dist/web-office-store.js +69 -10
  287. package/dist/web-office-store.js.map +1 -1
  288. package/dist/web-settings-patch.d.ts +2 -1
  289. package/dist/web-settings-patch.js +4 -1
  290. package/dist/web-settings-patch.js.map +1 -1
  291. package/dist/working-recovery.d.ts +2 -2
  292. package/dist/working-recovery.js +43 -8
  293. package/dist/working-recovery.js.map +1 -1
  294. package/dist/working-state-journal.d.ts +2 -0
  295. package/dist/working-state-journal.js +16 -1
  296. package/dist/working-state-journal.js.map +1 -1
  297. package/package.json +3 -2
  298. package/templates/windows-shortcuts.ps1 +155 -140
@@ -0,0 +1,754 @@
1
+ /**
2
+ * #2692: agents check, upload, replace, delete and restore the Office view's skin packs, and point a stored office at
3
+ * one — office_pack_check, office_pack_upload, office_pack_delete, office_pack_restore, office_pack_list,
4
+ * office_set_style. Packs are everyone's (every viewer of Fleet → Org chart → Office draws them), so:
5
+ *
6
+ * - The `office_packs` dashboard feature (dashboard-permissions.ts; `office_pack_access` in fleet.yaml) is off unless a
7
+ * person switches it on for the agent; the fleet's sinks refuse the tools before they reach here.
8
+ * - The same checks as the web upload (POST /ui/office-skins): checkSkinUpload — the page's own checkSkinPack, every
9
+ * picture's PNG walk and sizes, the limits (SKIN_LIMITS). The pack arrives as bytes in parts (web-office-pack-read.ts:
10
+ * the agent's side reads its folder); the fleet never opens a path an agent names.
11
+ * - The stored offices' layout overrides are checked against the new pack (overrideConflicts: checkLayoutOverrides'
12
+ * shape is the store's, applyOverrides puts each entry on the pack) for every office whose overrides were made on it —
13
+ * one that no longer fits would leave PUT /ui/offices refusing every save. Refused, naming each office and rule, unless
14
+ * `drop_invalid_overrides`, which drops exactly those entries (and the result names them).
15
+ * - A replace is atomic (OfficeSkinStore.put): the new version is checked and written first, then swapped in at once; on
16
+ * any failure the old one stays, and an offices write made for it is put back. One previous version is kept per pack.
17
+ * - Every change is logged (who, when, which pack, file count, bytes, the manifest's and each picture's sha256), in
18
+ * <dataDir>/web-office-skins/.agent-changes.log (JSON lines, the newest kept), and told to the fleet's admins in one
19
+ * short message (`notice`), never as a sign-in.
20
+ * - Approval (the admins' choice for the fleet, on the Office page): "apply" — at once; "approve" (the default) — the
21
+ * change waits as a pending change (memory only: a fleet restart drops it) until an admin approves it on the page or
22
+ * from the notice's buttons in General; it is judged again then.
23
+ * - A rate limit by batch and bytes (OfficePackBatchLimiter): 20 batches and 200 MiB an hour per agent; one call is one
24
+ * batch, however many packs it carries. Refusals are not counted; office_pack_check and office_pack_list are not.
25
+ */
26
+ import { createHash, randomBytes } from "node:crypto";
27
+ import { appendFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { atomicWriteFileSync } from "./atomic-write.js";
30
+ import { t } from "./locale.js";
31
+ import { applyOverrides } from "./ui/fleet-office-pack.js";
32
+ import { checkSkinUpload, SKIN_DISK_BYTES, SKIN_LIMITS } from "./web-office-skins.js";
33
+ import { PACK_BATCH_LIMITS, PACK_BATCH_WIRE_BYTES } from "./web-office-pack-read.js";
34
+ import { ASSET_PART_BYTES } from "./web-asset-parts.js";
35
+ import { OfficePackDeleteArgs, OfficePackListArgs, OfficePackRestoreArgs, OfficeSetStyleArgs, validateArgs } from "./outbound-schemas.js";
36
+ const MiB = 1024 * 1024, MINUTE_MS = 60_000, HOUR_MS = 60 * MINUTE_MS;
37
+ export const OFFICE_PACK_TOOL_LIMITS = {
38
+ batchesPerHour: 20, bytesPerHour: 200 * MiB,
39
+ /** Pending changes waiting for an admin's yes: at most 8 in the fleet, 200 MiB of pictures, each for 24 hours. */
40
+ pending: 8, pendingBytes: 200 * MiB, pendingMs: 24 * HOUR_MS,
41
+ /** The log keeps its newest entries: at most this many lines once it passes 512 KiB. */
42
+ logLines: 500, logBytes: 512 * 1024,
43
+ };
44
+ export const OFFICE_PACK_APPROVALS = ["apply", "approve"];
45
+ /** Until an admin chooses: every change waits for a yes. */
46
+ export const OFFICE_PACK_APPROVAL_DEFAULT = "approve";
47
+ /** The built-in styles an office may be drawn in besides a kept pack (fleet-office-skin.js BUILT_IN_SKINS). */
48
+ export const BUILT_IN_STYLES = ["pixel", "studio", "cozy"];
49
+ export const OFFICE_PACK_TOOLS = ["office_pack_check", "office_pack_upload", "office_pack_delete", "office_pack_restore", "office_pack_list", "office_set_style"];
50
+ const refuse = (error, message, extra = {}) => ({ error, message, ...extra });
51
+ const no = (error, message, extra = {}) => ({ refused: refuse(error, message, extra) });
52
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
53
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
54
+ export const mib = (bytes) => `${(bytes / MiB).toFixed(bytes < 10 * MiB ? 1 : 0)} MiB`;
55
+ /** A conflict no exact drop clears: the change is refused even with drop_invalid_overrides (never a whole layout lost). */
56
+ export const UNDROPPABLE = "overrides";
57
+ export const undroppable = (conflicts) => conflicts.some(c => c.entry === UNDROPPABLE);
58
+ /**
59
+ * Every entry of an office's overrides, each on its own (with `pack`): one per area's seats, item's place and item's
60
+ * night place, one per seat's reservation (seatFor.<area>.<slot>; an area's empty map is one entry too), and any other
61
+ * key whole (the door, the walkable graph). Every key there is, not a list of kinds: a key added later is checked too.
62
+ */
63
+ function entriesOf(ov) {
64
+ const out = [], pack = ov.pack;
65
+ const one = (entry, rest) => out.push({ entry, only: { pack, ...rest } });
66
+ for (const [kind, v] of Object.entries(ov)) {
67
+ if (kind === "pack")
68
+ continue;
69
+ if (kind === "seatFor" && isObj(v)) {
70
+ for (const [area, slots] of Object.entries(v)) {
71
+ const keys = isObj(slots) ? Object.keys(slots) : [];
72
+ if (!keys.length)
73
+ one(`seatFor.${area}`, { seatFor: { [area]: slots } });
74
+ for (const slot of keys)
75
+ one(`seatFor.${area}.${slot}`, { seatFor: { [area]: { [slot]: slots[slot] } } });
76
+ }
77
+ }
78
+ else if (isObj(v))
79
+ for (const key of Object.keys(v))
80
+ one(`${kind}.${key}`, { [kind]: { [key]: v[key] } });
81
+ else
82
+ one(kind, { [kind]: v });
83
+ }
84
+ return out;
85
+ }
86
+ /** `ov` with `entry` (an entriesOf one) taken out; empty maps go with it. */
87
+ function without(ov, entry) {
88
+ const [kind, key, slot] = entry.split(".");
89
+ if (key === undefined) {
90
+ delete ov[kind];
91
+ return;
92
+ }
93
+ if (!isObj(ov[kind]))
94
+ return;
95
+ if (slot !== undefined) {
96
+ if (isObj(ov[kind][key])) {
97
+ delete ov[kind][key][slot];
98
+ if (!Object.keys(ov[kind][key]).length)
99
+ delete ov[kind][key];
100
+ }
101
+ }
102
+ else
103
+ delete ov[kind][key];
104
+ if (!Object.keys(ov[kind]).length)
105
+ delete ov[kind];
106
+ }
107
+ /**
108
+ * Each stored office whose overrides were made on `pack` (overrides.pack: the ones PUT /ui/offices checks against it),
109
+ * put on it whole (applyOverrides, as that save does — every entry, the reservations too), and where that fails entry
110
+ * by entry: what does not fit. When the entries found are dropped the rest must fit whole; if not (a rule between
111
+ * entries), the office's conflict is UNDROPPABLE instead, and the change is refused.
112
+ */
113
+ export function overrideConflicts(offices, pack) {
114
+ const out = [];
115
+ for (const o of offices) {
116
+ const ov = o.overrides;
117
+ if (!ov || ov.pack !== pack.id)
118
+ continue;
119
+ const whole = applyOverrides(pack, ov);
120
+ if (whole.ok)
121
+ continue;
122
+ const mine = [];
123
+ for (const { entry, only } of entriesOf(ov)) {
124
+ const r = applyOverrides(pack, only);
125
+ if (!r.ok)
126
+ mine.push({ office: o.id, name: o.name, entry, rule: r.error.replace(/^overrides\./, "") });
127
+ }
128
+ const left = JSON.parse(JSON.stringify(ov));
129
+ for (const c of mine)
130
+ without(left, c.entry);
131
+ out.push(...(mine.length && applyOverrides(pack, left).ok ? mine
132
+ : [{ office: o.id, name: o.name, entry: UNDROPPABLE, rule: whole.error.replace(/^overrides\./, "") }]));
133
+ }
134
+ return out;
135
+ }
136
+ /** The offices with exactly those entries dropped (an office left with nothing but `pack` loses its overrides). An UNDROPPABLE conflict is never passed here. */
137
+ export function dropConflicts(offices, conflicts) {
138
+ if (undroppable(conflicts))
139
+ throw new Error("dropConflicts: an undroppable conflict");
140
+ return offices.map(o => {
141
+ const mine = conflicts.filter(c => c.office === o.id);
142
+ if (!mine.length || !o.overrides)
143
+ return o;
144
+ const { overrides: _gone, ...bare } = o;
145
+ const ov = JSON.parse(JSON.stringify(o.overrides));
146
+ for (const c of mine)
147
+ without(ov, c.entry);
148
+ return Object.keys(ov).some(k => k !== "pack") ? { ...o, overrides: ov } : bare;
149
+ });
150
+ }
151
+ /** How many dropped entries a notice names; the rest are counted. */
152
+ export const DROPS_SHOWN = 6;
153
+ /**
154
+ * What a change drops, for its notice (an admin approving from General sees it): the count, then each office with its
155
+ * entries — "Ops: seats.desks, furniture.rack; Den: …" — the first DROPS_SHOWN entries, "…and N more" after them.
156
+ */
157
+ export function dropsText(conflicts) {
158
+ const shown = conflicts.slice(0, DROPS_SHOWN), byOffice = new Map();
159
+ for (const c of shown) {
160
+ const g = byOffice.get(c.office) ?? { name: c.name || c.office, entries: [] };
161
+ if (!g.entries.includes(c.entry))
162
+ g.entries.push(c.entry);
163
+ byOffice.set(c.office, g);
164
+ }
165
+ const listed = [...byOffice.values()].map(g => `${g.name}: ${g.entries.join(", ")}`).join("; ");
166
+ const more = conflicts.length > DROPS_SHOWN ? ` ${t("office_packs.drops_more", conflicts.length - DROPS_SHOWN)}` : "";
167
+ return t("office_packs.drops", conflicts.length, listed + more);
168
+ }
169
+ const DROP_HINT = "Fix the pack, or send drop_invalid_overrides: true to drop exactly those entries.";
170
+ const UNDROPPABLE_HINT = "Those entries cannot be dropped one by one (the layout fails as a whole), so drop_invalid_overrides does not apply: fix the pack, or the office's layout on the Office page.";
171
+ const conflictText = (c) => c.map(x => `office "${x.name}" (${x.office}): ${x.rule}`).join("; ");
172
+ // ── The rate limit: batches and bytes ──
173
+ /** How much one agent may change: batches and bytes an hour (accepted calls only; a monotonic clock; memory only). */
174
+ export class OfficePackBatchLimiter {
175
+ now;
176
+ limits;
177
+ batches = new Map();
178
+ constructor(now = () => performance.now(), limits = OFFICE_PACK_TOOL_LIMITS) {
179
+ this.now = now;
180
+ this.limits = limits;
181
+ }
182
+ recent(instance, now) {
183
+ const kept = (this.batches.get(instance) ?? []).filter(b => now - b.t < HOUR_MS);
184
+ if (kept.length)
185
+ this.batches.set(instance, kept);
186
+ else
187
+ this.batches.delete(instance);
188
+ return kept;
189
+ }
190
+ /** Whether a batch of `bytes` may go now; else the ms until it may (never: larger than the hour's budget). */
191
+ check(instance, bytes) {
192
+ const now = this.now(), kept = this.recent(instance, now);
193
+ if (bytes > this.limits.bytesPerHour)
194
+ return { ok: false, retry_after_ms: null, why: `larger than the hour's budget of ${mib(this.limits.bytesPerHour)}` };
195
+ let wait = 0;
196
+ if (kept.length >= this.limits.batchesPerHour)
197
+ wait = HOUR_MS - (now - kept[kept.length - this.limits.batchesPerHour].t);
198
+ let used = kept.reduce((n, b) => n + b.bytes, 0);
199
+ for (const b of kept) {
200
+ if (used + bytes <= this.limits.bytesPerHour)
201
+ break;
202
+ used -= b.bytes;
203
+ wait = Math.max(wait, HOUR_MS - (now - b.t));
204
+ }
205
+ return wait > 0 ? { ok: false, retry_after_ms: Math.ceil(wait), why: `at most ${this.limits.batchesPerHour} batches and ${mib(this.limits.bytesPerHour)} an hour` } : { ok: true };
206
+ }
207
+ record(instance, bytes) { const now = this.now(); this.batches.set(instance, [...this.recent(instance, now), { t: now, bytes }]); }
208
+ /** What is left of the hour's budget. */
209
+ left(instance) {
210
+ const kept = this.recent(instance, this.now());
211
+ return { batches: Math.max(0, this.limits.batchesPerHour - kept.length), bytes: Math.max(0, this.limits.bytesPerHour - kept.reduce((n, b) => n + b.bytes, 0)) };
212
+ }
213
+ forget(instance) { this.batches.delete(instance); }
214
+ }
215
+ const PART_IDLE_MS = 60_000;
216
+ /**
217
+ * The receiving side of web-office-pack-read.ts's parts: one unfinished batch per instance and tool, dropped after a
218
+ * minute without a part; at most 8 at once and twice a batch's wire size in memory. The caller is the connection's
219
+ * instance, never a field of a part; no path is ever accepted.
220
+ */
221
+ export class OfficePackUploads {
222
+ clock;
223
+ pending = new Map();
224
+ constructor(clock = () => performance.now()) {
225
+ this.clock = clock;
226
+ }
227
+ forget(instance) { for (const k of [...this.pending.keys()])
228
+ if (k.startsWith(`${instance}\u0000`))
229
+ this.pending.delete(k); }
230
+ accept(instance, tool, args) {
231
+ const key = `${instance}\u0000${tool}`, now = this.clock();
232
+ const fail = (message) => { this.pending.delete(key); return { done: false, answer: refuse("pack_part", message) }; };
233
+ for (const [k, p] of this.pending)
234
+ if (now - p.last >= PART_IDLE_MS)
235
+ this.pending.delete(k);
236
+ if (!isObj(args))
237
+ return fail("expected a part of a pack batch");
238
+ if (Object.hasOwn(args, "dir") || Object.hasOwn(args, "files") || Object.hasOwn(args, "manifest") || Object.hasOwn(args, "packs")) {
239
+ return fail("the fleet takes a pack as parts of bytes, never a path or a manifest: send it through your tool (the agent's side reads the folder)");
240
+ }
241
+ const extra = Object.keys(args).find(k => !["data_b64", "part", "drop_invalid_overrides"].includes(k));
242
+ if (extra !== undefined)
243
+ return fail(`unknown key "${extra}"`);
244
+ const p = args.part;
245
+ if (typeof args.data_b64 !== "string" || args.data_b64.length > Math.ceil(ASSET_PART_BYTES / 3) * 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(args.data_b64) || !isObj(p))
246
+ return fail("a part: { data_b64, part }");
247
+ const keys = Object.keys(p).sort().join(",");
248
+ if (keys !== "bytes,count,index,sha256" && keys !== "bytes,count,index,sha256,upload")
249
+ return fail("part: { index, count, bytes, sha256, upload? }");
250
+ const { index, count, bytes } = p;
251
+ if (![index, count, bytes].every(Number.isSafeInteger) || typeof p.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(p.sha256))
252
+ return fail("part: numbers and a sha256");
253
+ if (bytes < 1 || bytes > PACK_BATCH_WIRE_BYTES || count !== Math.ceil(bytes / ASSET_PART_BYTES) || index < 0 || index >= count)
254
+ return fail(`part: at most ${mib(PACK_BATCH_WIRE_BYTES)} in all, in parts of ${ASSET_PART_BYTES} bytes`);
255
+ const chunk = Buffer.from(args.data_b64, "base64");
256
+ if (chunk.toString("base64") !== args.data_b64 || chunk.length !== (index < count - 1 ? ASSET_PART_BYTES : bytes - index * ASSET_PART_BYTES))
257
+ return fail("part: its bytes");
258
+ if (index === 0) {
259
+ if (p.upload !== undefined)
260
+ return fail("part 0 starts a batch");
261
+ if (args.drop_invalid_overrides !== undefined && typeof args.drop_invalid_overrides !== "boolean")
262
+ return fail("drop_invalid_overrides: true or false");
263
+ if (!this.pending.has(key) && this.pending.size >= 8)
264
+ return fail("too many batches arriving at once: try again shortly");
265
+ this.pending.set(key, { id: randomBytes(16).toString("hex"), count, next: 0, bytes, sha256: p.sha256, chunks: [], received: 0, last: now,
266
+ options: args.drop_invalid_overrides === undefined ? {} : { drop_invalid_overrides: args.drop_invalid_overrides } });
267
+ }
268
+ else if (args.drop_invalid_overrides !== undefined)
269
+ return fail("options go on part 0");
270
+ const up = this.pending.get(key);
271
+ if (!up || (index !== 0 && p.upload !== up.id) || index !== up.next || bytes !== up.bytes || count !== up.count || p.sha256 !== up.sha256)
272
+ return fail("part: not the next part of this batch");
273
+ if ([...this.pending.values()].reduce((n, x) => n + x.received, chunk.length) > 2 * PACK_BATCH_WIRE_BYTES)
274
+ return fail("too much arriving at once: try again shortly");
275
+ up.chunks.push(chunk);
276
+ up.received += chunk.length;
277
+ up.next++;
278
+ up.last = now;
279
+ if (up.next < up.count)
280
+ return { done: false, answer: { upload: up.id, received: up.received } };
281
+ this.pending.delete(key);
282
+ const all = Buffer.concat(up.chunks);
283
+ if (all.length !== up.bytes || sha256(all) !== up.sha256)
284
+ return { done: false, answer: refuse("pack_part", "the batch's sha256 does not match") };
285
+ try {
286
+ return { done: true, doc: JSON.parse(all.toString("utf8")), options: up.options };
287
+ }
288
+ catch {
289
+ return { done: false, answer: refuse("pack_part", "the batch is not JSON") };
290
+ }
291
+ }
292
+ }
293
+ export class OfficePackTools {
294
+ deps;
295
+ limiter;
296
+ uploads;
297
+ pending = new Map();
298
+ now;
299
+ approvalCache = null;
300
+ constructor(deps, opts = {}) {
301
+ this.deps = deps;
302
+ this.limiter = opts.limiter ?? new OfficePackBatchLimiter();
303
+ this.uploads = opts.uploads ?? new OfficePackUploads();
304
+ this.now = opts.now ?? (() => Date.now());
305
+ }
306
+ get settingsPath() { return join(this.deps.skins.dir, ".agent-changes.json"); }
307
+ get logPath() { return join(this.deps.skins.dir, ".agent-changes.log"); }
308
+ /** The admins' choice: "apply" (at once) or "approve" (a pending change until an admin's yes). Kept beside the packs. */
309
+ approval() {
310
+ if (this.approvalCache)
311
+ return this.approvalCache;
312
+ let mode = OFFICE_PACK_APPROVAL_DEFAULT;
313
+ try {
314
+ const d = JSON.parse(readFileSync(this.settingsPath, "utf8"));
315
+ if (OFFICE_PACK_APPROVALS.includes(d?.approval))
316
+ mode = d.approval;
317
+ }
318
+ catch { /* the default */ }
319
+ return (this.approvalCache = mode);
320
+ }
321
+ setApproval(mode, by) {
322
+ if (!OFFICE_PACK_APPROVALS.includes(mode))
323
+ return { ok: false, error: `approval: ${OFFICE_PACK_APPROVALS.join(" or ")}` };
324
+ try {
325
+ mkdirSync(this.deps.skins.dir, { recursive: true, mode: 0o700 });
326
+ atomicWriteFileSync(this.settingsPath, JSON.stringify({ version: 1, approval: mode }) + "\n", { mode: 0o600 });
327
+ }
328
+ catch (err) {
329
+ return { ok: false, error: `not saved: ${err.message}` };
330
+ }
331
+ this.approvalCache = mode;
332
+ this.log({ by, action: "approval", approval: mode });
333
+ this.deps.changed({ changes: true });
334
+ return { ok: true, approval: mode };
335
+ }
336
+ /** Append one entry to the log (and the fleet log); the newest are kept once it grows past its cap. */
337
+ log(entry) {
338
+ const full = { at: new Date(this.now()).toISOString(), ...entry };
339
+ try {
340
+ mkdirSync(this.deps.skins.dir, { recursive: true, mode: 0o700 });
341
+ appendFileSync(this.logPath, JSON.stringify(full) + "\n", { mode: 0o600 });
342
+ if (statSync(this.logPath).size > OFFICE_PACK_TOOL_LIMITS.logBytes) {
343
+ const lines = readFileSync(this.logPath, "utf8").split("\n").filter(Boolean).slice(-Math.floor(OFFICE_PACK_TOOL_LIMITS.logLines / 2));
344
+ atomicWriteFileSync(this.logPath, lines.join("\n") + "\n", { mode: 0o600 });
345
+ }
346
+ }
347
+ catch (err) {
348
+ this.deps.logger?.warn({ err: err.message }, "Office pack log could not be written");
349
+ }
350
+ this.deps.logger?.info({ ...full, pictures: undefined }, "Office pack change");
351
+ return full;
352
+ }
353
+ /** The newest log entries first. */
354
+ logEntries(limit = 20) {
355
+ try {
356
+ return readFileSync(this.logPath, "utf8").split("\n").filter(Boolean).slice(-limit).reverse()
357
+ .map(l => { try {
358
+ return JSON.parse(l);
359
+ }
360
+ catch {
361
+ return null;
362
+ } }).filter((e) => !!e);
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ }
368
+ /** A version's digests: the manifest's sha256 and each picture's (its kept bytes), from the upload or from disk. */
369
+ digest(r, pictures) {
370
+ const out = {};
371
+ for (const [src, f] of Object.entries(r.files)) {
372
+ let bytes = pictures?.get(f.id);
373
+ if (!bytes) {
374
+ try {
375
+ bytes = readFileSync(join(this.deps.skins.dir, r.id, `${f.id}.png`));
376
+ }
377
+ catch { /* gone */ }
378
+ }
379
+ out[src] = bytes ? sha256(bytes) : "";
380
+ }
381
+ return { manifest_sha256: sha256(JSON.stringify(r.manifest)), pictures: out };
382
+ }
383
+ recordOf(id) { return this.deps.skins.records().packs.find(r => r.id === id); }
384
+ sweep() {
385
+ const now = this.now();
386
+ for (const [id, p] of this.pending) {
387
+ if (p.view.expires_at > now)
388
+ continue;
389
+ this.pending.delete(id);
390
+ this.log({ by: p.view.instance, action: p.view.action, change: id, outcome: "expired", pack: p.view.packs[0]?.id });
391
+ try {
392
+ this.deps.settled(id, t("office_packs.expired", summaryOf(p.view)));
393
+ }
394
+ catch { /* the buttons expire themselves */ }
395
+ this.deps.changed({ changes: true });
396
+ }
397
+ }
398
+ /** The pending changes (all, or one agent's). */
399
+ pendingViews(instance) {
400
+ this.sweep();
401
+ return [...this.pending.values()].map(p => p.view).filter(v => !instance || v.instance === instance).map(v => JSON.parse(JSON.stringify(v)));
402
+ }
403
+ /** GET /ui/office-pack-changes: what the Office page shows an admin. */
404
+ changes() {
405
+ return { approval: this.approval(), agents: [...this.deps.allowedAgents()].sort(), pending: this.pendingViews(), log: this.logEntries() };
406
+ }
407
+ /** The tool call, from the instance its connection (or agent token) belongs to. */
408
+ handle(instance, tool, args) {
409
+ switch (tool) {
410
+ case "office_pack_list": {
411
+ const v = validateArgs(OfficePackListArgs, args ?? {}, tool);
412
+ return v.ok ? this.list(instance) : refuse("invalid", v.error);
413
+ }
414
+ case "office_pack_check":
415
+ case "office_pack_upload": {
416
+ const got = this.uploads.accept(instance, tool, args ?? {});
417
+ if (!got.done)
418
+ return got.answer;
419
+ const drop = got.options.drop_invalid_overrides === true;
420
+ const items = this.decode(got.doc, instance);
421
+ if (!Array.isArray(items))
422
+ return items.refused;
423
+ return tool === "office_pack_check" ? this.check(items, drop) : this.submit(instance, { action: "upload", items, drop });
424
+ }
425
+ case "office_pack_delete": {
426
+ const v = validateArgs(OfficePackDeleteArgs, args ?? {}, tool);
427
+ return v.ok ? this.submit(instance, { action: "delete", id: v.data.id, keepPrevious: v.data.keep_previous === true }) : refuse("invalid", v.error);
428
+ }
429
+ case "office_pack_restore": {
430
+ const v = validateArgs(OfficePackRestoreArgs, args ?? {}, tool);
431
+ return v.ok ? this.submit(instance, { action: "restore", id: v.data.id, drop: v.data.drop_invalid_overrides === true }) : refuse("invalid", v.error);
432
+ }
433
+ case "office_set_style": {
434
+ const v = validateArgs(OfficeSetStyleArgs, args ?? {}, tool);
435
+ return v.ok ? this.submit(instance, { action: "style", office: v.data.office, style: v.data.pack }) : refuse("invalid", v.error);
436
+ }
437
+ default: return refuse("unknown_tool", `${tool} is not an Office pack tool`);
438
+ }
439
+ }
440
+ list(instance) {
441
+ const rec = this.deps.skins.records(), offices = this.deps.offices.list().offices;
442
+ const brief = (r) => ({ version: r.sha, files: Object.keys(r.files).length, bytes: r.bytes, uploaded_at: r.uploaded_at, by: r.by });
443
+ const current = new Set(rec.packs.map(r => r.id));
444
+ return {
445
+ readable: rec.readable,
446
+ packs: rec.packs.map(r => ({ id: r.id, name: r.name, ...brief(r), previous: rec.previous.has(r.id) ? brief(rec.previous.get(r.id)) : null,
447
+ offices: offices.filter(o => o.skin === r.id).map(o => o.id) })),
448
+ deleted: [...rec.previous].filter(([id]) => !current.has(id)).map(([id, r]) => ({ id, name: r.name, previous: brief(r) })),
449
+ offices: offices.map(o => ({ id: o.id, name: o.name, style: o.skin, ...(o.overrides ? { overrides_on: o.overrides.pack } : {}) })),
450
+ built_in_styles: [...BUILT_IN_STYLES],
451
+ approval: this.approval(),
452
+ pending: this.pendingViews(instance),
453
+ limits: { packs: SKIN_LIMITS.packs, files: SKIN_LIMITS.files, pack_bytes: SKIN_LIMITS.packBytes, file_bytes: SKIN_LIMITS.fileBytes, disk_bytes: SKIN_DISK_BYTES,
454
+ batch_packs: PACK_BATCH_LIMITS.packs, batch_bytes: PACK_BATCH_LIMITS.bytes, batches_per_hour: OFFICE_PACK_TOOL_LIMITS.batchesPerHour, bytes_per_hour: OFFICE_PACK_TOOL_LIMITS.bytesPerHour },
455
+ disk_bytes: rec.diskBytes,
456
+ left: this.limiter.left(instance),
457
+ };
458
+ }
459
+ /** The batch document, each pack checked as the web upload checks it. */
460
+ decode(doc, instance) {
461
+ if (!isObj(doc) || !Array.isArray(doc.packs) || !doc.packs.length || Object.keys(doc).some(k => k !== "packs"))
462
+ return no("pack_invalid", "a batch: { packs: [...] }");
463
+ if (doc.packs.length > PACK_BATCH_LIMITS.packs)
464
+ return no("pack_invalid", `at most ${PACK_BATCH_LIMITS.packs} packs in one batch`);
465
+ const items = [], ids = new Set();
466
+ for (const [i, body] of doc.packs.entries()) {
467
+ const c = checkSkinUpload(body, `agent:${instance}`, this.now());
468
+ const at = doc.packs.length > 1 ? `packs[${i}]` : "the pack";
469
+ if (!c.ok)
470
+ return no(c.error, `${at}: ${c.detail ?? c.error}`, { pack: i });
471
+ if (ids.has(c.record.id.toLowerCase()))
472
+ return no("pack_invalid", `${at}: "${c.record.id}" is in the batch twice`, { pack: i });
473
+ ids.add(c.record.id.toLowerCase());
474
+ items.push(c);
475
+ }
476
+ const total = items.reduce((n, c) => n + c.record.bytes, 0);
477
+ if (total > PACK_BATCH_LIMITS.bytes)
478
+ return no("pack_invalid", `at most ${mib(PACK_BATCH_LIMITS.bytes)} of pictures in one batch`);
479
+ return items;
480
+ }
481
+ /** Whether the checked packs may go in now: the pack count, the disk budget, the offices' overrides. */
482
+ fit(items, drop) {
483
+ const rec = this.deps.skins.records();
484
+ if (!rec.readable)
485
+ return { ok: false, answer: refuse("skins_unreadable", "The fleet's packs cannot be read now.") };
486
+ const current = new Map(rec.packs.map(r => [r.id, r]));
487
+ const added = items.filter(c => !current.has(c.record.id)).length;
488
+ if (current.size + added > SKIN_LIMITS.packs)
489
+ return { ok: false, answer: refuse("skins_quota", `at most ${SKIN_LIMITS.packs} packs: the fleet keeps ${current.size}, this adds ${added}. Delete one first.`) };
490
+ const offices = this.deps.offices.list().offices;
491
+ const packs = items.map(c => ({ c, conflicts: overrideConflicts(offices, c.pack), replaces: current.get(c.record.id) }));
492
+ const bad = packs.filter(p => p.conflicts.length);
493
+ if (bad.length && (!drop || bad.some(p => undroppable(p.conflicts)))) {
494
+ const conflicts = bad.flatMap(p => p.conflicts.map(x => ({ pack: p.c.record.id, ...x })));
495
+ return { ok: false, answer: refuse("overrides_invalid", `A stored office's layout overrides would no longer fit — ${bad.map(p => `${p.c.record.id}: ${conflictText(p.conflicts)}`).join(" | ")}. ${undroppable(conflicts) ? UNDROPPABLE_HINT : DROP_HINT}`, { conflicts }) };
496
+ }
497
+ return { ok: true, packs };
498
+ }
499
+ check(items, drop) {
500
+ const f = this.fit(items, drop);
501
+ if (!f.ok)
502
+ return f.answer;
503
+ return { result: "PACK OK", packs: f.packs.map(({ c, conflicts, replaces }) => ({
504
+ id: c.record.id, name: c.record.name, files: Object.keys(c.record.files).length, bytes: c.record.bytes, version: c.record.sha,
505
+ replaces: replaces ? replaces.sha : null, unchanged: replaces?.sha === c.record.sha,
506
+ offices: this.deps.offices.list().offices.filter(o => o.skin === c.record.id).map(o => o.id),
507
+ ...(conflicts.length ? { drops: conflicts } : {})
508
+ })) };
509
+ }
510
+ /** A change from an agent: judged, counted, then applied at once or held for an admin's yes. */
511
+ submit(instance, change) {
512
+ this.sweep();
513
+ const judged = this.judge(change);
514
+ if ("refused" in judged)
515
+ return judged.refused;
516
+ const bytes = change.action === "upload" ? change.items.reduce((n, c) => n + c.record.bytes, 0) : 0;
517
+ const allowed = this.limiter.check(instance, bytes);
518
+ if (!allowed.ok)
519
+ return refuse("rate_limited", `Too many pack changes (${allowed.why})${allowed.retry_after_ms === null ? "." : `; try again in ${Math.ceil(allowed.retry_after_ms / 1000)} s.`}`, { retry_after_ms: allowed.retry_after_ms, left: this.limiter.left(instance) });
520
+ if (this.approval() === "approve") {
521
+ if (this.pending.size >= OFFICE_PACK_TOOL_LIMITS.pending)
522
+ return refuse("pending_full", `${OFFICE_PACK_TOOL_LIMITS.pending} changes already wait for an admin's yes: try again once they are decided.`);
523
+ if ([...this.pending.values()].reduce((n, p) => n + p.bytes, bytes) > OFFICE_PACK_TOOL_LIMITS.pendingBytes)
524
+ return refuse("pending_full", `${mib(OFFICE_PACK_TOOL_LIMITS.pendingBytes)} of packs already wait for an admin's yes.`);
525
+ const at = this.now(), id = randomBytes(16).toString("hex");
526
+ const view = { id, instance, at, expires_at: at + OFFICE_PACK_TOOL_LIMITS.pendingMs, action: change.action, packs: judged.packs,
527
+ ...(change.action === "style" ? { office: change.office, style: change.style } : {}),
528
+ ...(change.action === "delete" ? { keep_previous: change.keepPrevious } : {}),
529
+ ...((change.action === "upload" || change.action === "restore") && change.drop ? { drop_invalid_overrides: true } : {}),
530
+ ...(judged.drops.length ? { drops: judged.drops } : {}) };
531
+ this.pending.set(id, { view, spec: change, bytes });
532
+ this.limiter.record(instance, bytes);
533
+ this.log({ by: instance, action: change.action, change: id, outcome: "pending", ...this.logFields(change, judged.packs) });
534
+ this.deps.notice(t("office_packs.pending", instance, summaryOf(view), Math.round(OFFICE_PACK_TOOL_LIMITS.pendingMs / HOUR_MS)), view);
535
+ this.deps.changed({ changes: true });
536
+ return { pending: JSON.parse(JSON.stringify(view)), message: "This change waits for a fleet admin's yes (on the Office page, or from the notice in General); it is judged again then. office_pack_list shows it while it waits." };
537
+ }
538
+ const r = this.apply(instance, change, null, null);
539
+ if ("refused" in r) {
540
+ // A batch that stopped part-way: the packs before the failure are in (each atomically) — counted and told.
541
+ const done = Array.isArray(r.refused.applied) ? r.refused.applied : [];
542
+ if (done.length) {
543
+ this.limiter.record(instance, bytes);
544
+ this.deps.notice(t("office_packs.applied", instance, `${t("office_packs.uploaded", done.map(a => a.id).join(", "))} — ${String(r.refused.message ?? r.refused.error)}`));
545
+ }
546
+ return r.refused;
547
+ }
548
+ this.limiter.record(instance, bytes);
549
+ this.deps.notice(t("office_packs.applied", instance, r.summary));
550
+ return r.answer;
551
+ }
552
+ logFields(change, packs) {
553
+ return { pack: packs.map(p => p.id).join(",") || undefined, ...(change.action === "style" ? { office: change.office, style: change.style } : {}) };
554
+ }
555
+ /** What a change would do, or why it cannot: the packs it names (for the view), the override entries it drops. */
556
+ judge(change) {
557
+ const rec = this.deps.skins.records();
558
+ if (!rec.readable)
559
+ return no("skins_unreadable", "The fleet's packs cannot be read now.");
560
+ const current = new Map(rec.packs.map(r => [r.id, r]));
561
+ switch (change.action) {
562
+ case "upload": {
563
+ const f = this.fit(change.items, change.drop);
564
+ if (!f.ok)
565
+ return { refused: f.answer };
566
+ return { packs: f.packs.map(({ c, replaces }) => ({ id: c.record.id, name: c.record.name, files: Object.keys(c.record.files).length, bytes: c.record.bytes, replaces: replaces?.sha ?? null })),
567
+ drops: f.packs.flatMap(p => p.conflicts) };
568
+ }
569
+ case "delete": {
570
+ const r = current.get(change.id), prev = rec.previous.get(change.id);
571
+ if (!r && !prev)
572
+ return no("skin_unknown", `The fleet keeps no pack "${change.id}" (office_pack_list lists them).`);
573
+ if (!r && change.keepPrevious)
574
+ return no("skin_unknown", `"${change.id}" is deleted already; its previous version is kept.`);
575
+ return { packs: [{ id: change.id, name: (r ?? prev).name, files: Object.keys((r ?? prev).files).length, bytes: (r ?? prev).bytes }], drops: [] };
576
+ }
577
+ case "restore": {
578
+ const prev = rec.previous.get(change.id), pack = this.deps.skins.previousPack(change.id);
579
+ if (!prev || !pack)
580
+ return no("skin_no_previous", `"${change.id}" has no previous version kept.`);
581
+ if (!current.has(change.id) && current.size >= SKIN_LIMITS.packs)
582
+ return no("skins_quota", `at most ${SKIN_LIMITS.packs} packs`);
583
+ const conflicts = overrideConflicts(this.deps.offices.list().offices, pack);
584
+ if (conflicts.length && (!change.drop || undroppable(conflicts)))
585
+ return no("overrides_invalid", `A stored office's layout overrides would no longer fit the restored version — ${conflictText(conflicts)}. ${undroppable(conflicts) ? UNDROPPABLE_HINT : "Send drop_invalid_overrides: true to drop exactly those entries."}`, { conflicts });
586
+ return { packs: [{ id: change.id, name: prev.name, files: Object.keys(prev.files).length, bytes: prev.bytes, replaces: current.get(change.id)?.sha ?? null }], drops: conflicts };
587
+ }
588
+ case "style": {
589
+ const offices = this.deps.offices.list();
590
+ const o = offices.offices.find(x => x.id === change.office);
591
+ if (!offices.readable)
592
+ return no("offices_unreadable", "The stored offices cannot be read now.");
593
+ if (!o)
594
+ return no("office", `There is no stored office "${change.office}". Stored: ${offices.offices.map(x => x.id).join(", ") || "none"}.`);
595
+ const r = current.get(change.style);
596
+ if (!r && !BUILT_IN_STYLES.includes(change.style))
597
+ return no("style", `"${change.style}" is neither a pack the fleet keeps nor a built-in style (${BUILT_IN_STYLES.join(", ")}).`);
598
+ const pack = r ? this.deps.skins.pack(change.style) : null;
599
+ const conflicts = pack ? overrideConflicts([o], pack) : [];
600
+ if (conflicts.length)
601
+ return no("overrides_invalid", `Its layout overrides do not fit "${change.style}" — ${conflictText(conflicts)}.`, { conflicts });
602
+ return { packs: r ? [{ id: r.id, name: r.name }] : [{ id: change.style, name: change.style }], drops: [] };
603
+ }
604
+ }
605
+ }
606
+ /** Make the change (judged again first). Its answer for the agent, and a one-line summary for the notice. */
607
+ apply(instance, change, approvedBy, changeId) {
608
+ const judged = this.judge(change);
609
+ if ("refused" in judged)
610
+ return judged;
611
+ const extra = { ...(approvedBy ? { approved_by: approvedBy } : {}), ...(changeId ? { change: changeId } : {}) };
612
+ switch (change.action) {
613
+ case "upload": {
614
+ const applied = [];
615
+ let skinsRev, officesRev;
616
+ const done = (failure) => {
617
+ if (skinsRev !== undefined || officesRev !== undefined)
618
+ this.deps.changed({ ...(skinsRev !== undefined ? { skins: skinsRev } : {}), ...(officesRev !== undefined ? { offices: officesRev } : {}), changes: true });
619
+ return failure ? { refused: failure } : undefined;
620
+ };
621
+ for (const c of change.items) {
622
+ const before = this.deps.offices.list();
623
+ const conflicts = overrideConflicts(before.offices, c.pack);
624
+ if (conflicts.length && (!change.drop || undroppable(conflicts)))
625
+ return done(refuse("overrides_invalid", `${c.record.id}: ${conflictText(conflicts)}`, { conflicts, applied }));
626
+ let wrote = null;
627
+ if (conflicts.length) {
628
+ const w = this.deps.offices.replace(dropConflicts(before.offices, conflicts), before.rev, { packOf: id => (id === c.pack.id ? c.pack : this.deps.skins.pack(id)) });
629
+ if (!w.ok)
630
+ return done(refuse(w.error, `${c.record.id}: the offices could not be updated (${w.detail ?? w.error}); nothing of it was stored`, { applied }));
631
+ wrote = w.rev;
632
+ }
633
+ const replaced = this.recordOf(c.record.id);
634
+ const put = this.deps.skins.put(c, { replace: true });
635
+ if (!put.ok) {
636
+ // The pack stays as it was: so do the offices.
637
+ if (wrote !== null) {
638
+ const back = this.deps.offices.replace(before.offices, wrote, { packOf: () => null });
639
+ if (back.ok)
640
+ officesRev = back.rev;
641
+ }
642
+ return done(refuse(put.error, `${c.record.id}: ${put.detail ?? put.error}; the version kept before stays`, { applied, failed: c.record.id }));
643
+ }
644
+ if (wrote !== null)
645
+ officesRev = wrote;
646
+ skinsRev = put.rev;
647
+ const entry = this.log({ by: instance, action: replaced ? "replace" : "upload", pack: c.record.id, name: c.record.name, version: c.record.sha,
648
+ files: Object.keys(c.record.files).length, bytes: c.record.bytes, ...this.digest(c.record, c.pictures), previous_kept: !!replaced,
649
+ replaced_version: replaced?.sha ?? null, ...(conflicts.length ? { dropped_overrides: conflicts } : {}), ...extra });
650
+ applied.push({ id: c.record.id, name: c.record.name, version: c.record.sha, files: entry.files, bytes: c.record.bytes, replaced: replaced?.sha ?? null,
651
+ previous_kept: !!replaced, ...(conflicts.length ? { dropped_overrides: conflicts } : {}), manifest_sha256: entry.manifest_sha256 });
652
+ }
653
+ done();
654
+ return { answer: { applied }, summary: t("office_packs.uploaded", applied.map(a => t("office_packs.upload_item", String(a.id), String(a.name), Number(Object.keys(change.items.find(c => c.record.id === a.id).record.files).length), mib(Number(a.bytes)))
655
+ + (a.replaced ? ` ${t("office_packs.replaces", String(a.replaced).slice(0, 8))}` : "")
656
+ + (Array.isArray(a.dropped_overrides) ? ` ${dropsText(a.dropped_overrides)}` : "")).join("; ")) };
657
+ }
658
+ case "delete": {
659
+ const r = this.recordOf(change.id) ?? this.deps.skins.records().previous.get(change.id);
660
+ const digest = this.digest(r);
661
+ const using = this.deps.offices.list().offices.filter(o => o.skin === change.id).map(o => o.id);
662
+ const gone = this.deps.skins.remove(change.id, { keepPrevious: change.keepPrevious });
663
+ if (!gone.ok)
664
+ return no(gone.error, gone.detail ?? gone.error);
665
+ this.log({ by: instance, action: "delete", pack: change.id, name: r.name, version: r.sha, files: Object.keys(r.files).length, bytes: r.bytes, ...digest, previous_kept: gone.previous_kept, ...extra });
666
+ this.deps.changed({ skins: gone.rev, changes: true });
667
+ return { answer: { applied: { deleted: change.id, version: r.sha, previous_kept: gone.previous_kept, offices_now_built_in: using } },
668
+ summary: t(gone.previous_kept ? "office_packs.deleted_keep" : "office_packs.deleted", change.id) };
669
+ }
670
+ case "restore": {
671
+ let wrote = null;
672
+ const before = this.deps.offices.list();
673
+ const pack = this.deps.skins.previousPack(change.id);
674
+ if (judged.drops.length) {
675
+ const w = this.deps.offices.replace(dropConflicts(before.offices, judged.drops), before.rev, { packOf: id => (id === pack.id ? pack : this.deps.skins.pack(id)) });
676
+ if (!w.ok)
677
+ return no(w.error, `the offices could not be updated (${w.detail ?? w.error}); nothing was restored`);
678
+ wrote = w.rev;
679
+ }
680
+ const r = this.deps.skins.restore(change.id);
681
+ if (!r.ok) {
682
+ if (wrote !== null) {
683
+ const back = this.deps.offices.replace(before.offices, wrote, { packOf: () => null });
684
+ if (back.ok)
685
+ this.deps.changed({ offices: back.rev });
686
+ }
687
+ return no(r.error, `${r.detail ?? r.error}; the version kept before stays`);
688
+ }
689
+ const now = this.recordOf(change.id);
690
+ this.log({ by: instance, action: "restore", pack: change.id, name: now.name, version: now.sha, files: Object.keys(now.files).length, bytes: now.bytes, ...this.digest(now),
691
+ replaced_version: r.replaced?.sha ?? null, previous_kept: !!r.replaced, ...(judged.drops.length ? { dropped_overrides: judged.drops } : {}), ...extra });
692
+ this.deps.changed({ skins: r.rev, ...(wrote !== null ? { offices: wrote } : {}), changes: true });
693
+ return { answer: { applied: { restored: change.id, version: now.sha, replaced: r.replaced?.sha ?? null, ...(judged.drops.length ? { dropped_overrides: judged.drops } : {}) } },
694
+ summary: t("office_packs.restore", change.id, now.sha.slice(0, 8)) + (judged.drops.length ? ` ${dropsText(judged.drops)}` : "") };
695
+ }
696
+ case "style": {
697
+ const list = this.deps.offices.list();
698
+ const next = list.offices.map(o => (o.id === change.office ? { ...o, skin: change.style } : o));
699
+ const w = this.deps.offices.replace(next, list.rev);
700
+ if (!w.ok)
701
+ return no(w.error, w.detail ?? w.error);
702
+ const was = list.offices.find(o => o.id === change.office).skin;
703
+ this.log({ by: instance, action: "style", office: change.office, style: change.style, pack: change.style, ...extra });
704
+ this.deps.changed({ offices: w.rev, changes: true });
705
+ return { answer: { applied: { office: change.office, style: change.style, was } }, summary: t("office_packs.styled", change.office, change.style) };
706
+ }
707
+ }
708
+ }
709
+ /**
710
+ * An admin's yes or no on a pending change (the Office page, or the notice's buttons). A yes judges it again and
711
+ * applies it; what happened is the answer (and the notice's new text).
712
+ */
713
+ decide(id, decision, by) {
714
+ this.sweep();
715
+ const p = this.pending.get(id);
716
+ if (!p)
717
+ return { ok: false, status: 404, error: "change_unknown", text: t("office_packs.gone") };
718
+ this.pending.delete(id);
719
+ const spec = p.spec;
720
+ const summary = summaryOf(p.view);
721
+ if (decision === "reject") {
722
+ this.log({ by: p.view.instance, action: p.view.action, change: id, outcome: "rejected", approved_by: by, pack: p.view.packs.map(x => x.id).join(",") });
723
+ const text = t("office_packs.rejected", by, p.view.instance, summary);
724
+ this.deps.settled(id, text);
725
+ this.deps.changed({ changes: true });
726
+ return { ok: true, text };
727
+ }
728
+ const r = this.apply(p.view.instance, spec, by, id);
729
+ if ("refused" in r) {
730
+ const why = String(r.refused.message ?? r.refused.error);
731
+ this.log({ by: p.view.instance, action: p.view.action, change: id, outcome: `failed: ${why}`, approved_by: by, pack: p.view.packs.map(x => x.id).join(",") });
732
+ const text = t("office_packs.failed", p.view.instance, summary, why);
733
+ this.deps.settled(id, text);
734
+ this.deps.changed({ changes: true });
735
+ return { ok: false, status: 409, error: String(r.refused.error), text };
736
+ }
737
+ const text = t("office_packs.approved", by, p.view.instance, r.summary);
738
+ this.deps.settled(id, text);
739
+ return { ok: true, text, applied: r.answer };
740
+ }
741
+ forget(instance) { this.limiter.forget(instance); this.uploads.forget(instance); }
742
+ }
743
+ /** One line for a notice: what a pending change would do. */
744
+ export function summaryOf(v) {
745
+ const ids = v.packs.map(p => p.id).join(", ");
746
+ switch (v.action) {
747
+ case "upload": return t("office_packs.upload", v.packs.map(p => t("office_packs.upload_item", p.id, p.name, p.files ?? 0, mib(p.bytes ?? 0)) + (p.replaces ? ` ${t("office_packs.replaces", p.replaces.slice(0, 8))}` : "")).join("; ")
748
+ + (v.drops?.length ? ` ${dropsText(v.drops)}` : ""));
749
+ case "delete": return t(v.keep_previous ? "office_packs.delete_keep" : "office_packs.delete", ids);
750
+ case "restore": return t("office_packs.restore_pending", ids) + (v.drops?.length ? ` ${dropsText(v.drops)}` : "");
751
+ case "style": return t("office_packs.style", v.office ?? "", v.style ?? "");
752
+ }
753
+ }
754
+ //# sourceMappingURL=web-office-pack-tools.js.map