@huynle/hub 0.8.6 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +69 -12
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +278 -35
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
- package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +56 -5
- package/packages/protocol/src/launch.ts +7 -2
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +18 -1
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
- package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-TwvrwqSk.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-D-xG9nrc.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-7q72Lyj2.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- package/web/dist/assets/ui-j5FFk90B.js +0 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// (Session media §3, item 4b) The hub's side of the spoke op previewBundle (packages/protocol/src/preview-bundle.ts):
|
|
2
|
+
// the manifest first, then every file the spoke did not inline, chunk by chunk, each checked against the manifest
|
|
3
|
+
// (sizes, SHA-256) before the preview is stored. A bundle that changes on the spoke while it is read (`stale`) is read
|
|
4
|
+
// again from the start, a few times at most.
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { PREVIEW_BUNDLE_LIMITS, previewBundleResultProblem, type PreviewBundleResult, type PreviewBundleSkip } from "../../protocol"
|
|
7
|
+
import type { PreviewBundleContent } from "./store"
|
|
8
|
+
|
|
9
|
+
/** One previewBundle request of the page (its message and path are the caller's): the manifest, or a chunk. */
|
|
10
|
+
export type BundleAsk = (chunk?: { readonly bundle: string; readonly file: number; readonly offset: number }) => Promise<PreviewBundleResult>
|
|
11
|
+
|
|
12
|
+
export type FetchedBundle =
|
|
13
|
+
| { readonly bundle: PreviewBundleContent; readonly skipped: readonly PreviewBundleSkip[]; readonly skippedMore?: number }
|
|
14
|
+
| { readonly error: "not_found" | "hidden" | "refused" | "changed" }
|
|
15
|
+
| { readonly error: "too_large"; readonly bytes: number; readonly max: number }
|
|
16
|
+
|
|
17
|
+
/** A spoke answer that breaks the protocol (the route answers 502). */
|
|
18
|
+
export class BundleProtocolError extends Error {}
|
|
19
|
+
|
|
20
|
+
/** Reads of a bundle that keeps changing before the hub gives up ("changed"). */
|
|
21
|
+
const ATTEMPTS = 3
|
|
22
|
+
|
|
23
|
+
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex")
|
|
24
|
+
|
|
25
|
+
function checked(result: unknown): PreviewBundleResult {
|
|
26
|
+
const problem = previewBundleResultProblem(result)
|
|
27
|
+
if (problem) throw new BundleProtocolError(`The spoke's preview bundle is malformed: ${problem}`)
|
|
28
|
+
return result as PreviewBundleResult
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The page's bundle from the spoke (see the file's header). */
|
|
32
|
+
export async function fetchPreviewBundle(ask: BundleAsk): Promise<FetchedBundle> {
|
|
33
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
34
|
+
const first = checked(await ask())
|
|
35
|
+
if (first.refused === "hidden") return { error: "hidden" }
|
|
36
|
+
if (first.refused !== undefined) return { error: "refused" }
|
|
37
|
+
if (first.tooLarge) return { error: "too_large", bytes: first.tooLarge.bytes, max: first.tooLarge.max }
|
|
38
|
+
if (first.bundle === undefined || first.files === undefined || first.entry === undefined) {
|
|
39
|
+
if (first.chunk !== undefined || first.stale !== undefined) throw new BundleProtocolError("The spoke answered a manifest request with a chunk")
|
|
40
|
+
return { error: "not_found" }
|
|
41
|
+
}
|
|
42
|
+
const total = first.files.reduce((n, f) => n + f.bytes, 0)
|
|
43
|
+
if (total > PREVIEW_BUNDLE_LIMITS.totalBytes) throw new BundleProtocolError("The spoke's preview bundle is over its total size cap")
|
|
44
|
+
const files: Array<{ path: string; mediaType: string; body: Uint8Array }> = []
|
|
45
|
+
let stale = false
|
|
46
|
+
for (const [index, file] of first.files.entries()) {
|
|
47
|
+
let body: Uint8Array
|
|
48
|
+
if (file.data !== undefined) body = Uint8Array.from(Buffer.from(file.data, "base64"))
|
|
49
|
+
else {
|
|
50
|
+
const read = await readChunks(ask, first.bundle, index, file.bytes)
|
|
51
|
+
if (read === "stale") {
|
|
52
|
+
stale = true
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
body = read
|
|
56
|
+
}
|
|
57
|
+
if (body.length !== file.bytes || sha256(body) !== file.sha256) throw new BundleProtocolError(`The spoke's file ${file.path} does not match its manifest`)
|
|
58
|
+
files.push({ path: file.path, mediaType: file.mediaType, body })
|
|
59
|
+
}
|
|
60
|
+
if (stale) continue
|
|
61
|
+
return { bundle: { entry: first.entry, files }, skipped: first.skipped ?? [], ...(first.skippedMore ? { skippedMore: first.skippedMore } : {}) }
|
|
62
|
+
}
|
|
63
|
+
return { error: "changed" }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A file's bytes in chunks from offset 0 (none for an empty file), or "stale". */
|
|
67
|
+
async function readChunks(ask: BundleAsk, bundle: string, file: number, bytes: number): Promise<Uint8Array | "stale"> {
|
|
68
|
+
const out = new Uint8Array(bytes)
|
|
69
|
+
let offset = 0
|
|
70
|
+
while (offset < bytes) {
|
|
71
|
+
const answer = checked(await ask({ bundle, file, offset }))
|
|
72
|
+
if (answer.stale) return "stale"
|
|
73
|
+
const chunk = answer.chunk
|
|
74
|
+
if (!chunk || chunk.offset !== offset) throw new BundleProtocolError("The spoke answered a chunk request without that chunk")
|
|
75
|
+
const data = Buffer.from(chunk.data, "base64")
|
|
76
|
+
if (data.length === 0 || offset + data.length > bytes || chunk.last !== (offset + data.length === bytes)) throw new BundleProtocolError("The spoke's chunk does not fit its file")
|
|
77
|
+
out.set(data, offset)
|
|
78
|
+
offset += data.length
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// HTML previews of code blocks in session messages (session media §3, item 4a). The hub reads the message itself,
|
|
2
|
+
// from the session's spoke (the timeline op, newest-first pages), and never takes content from the console:
|
|
3
|
+
// - messageTexts: the text of one message's assistant text parts, in order (what the console renders as Markdown);
|
|
4
|
+
// - htmlBlocks: the fenced ```html / ```htm blocks of one text, parsed by the console's own Markdown parser
|
|
5
|
+
// (packages/protocol/src/markdown.ts, which web/src/lib/markdown.ts re-exports).
|
|
6
|
+
// Block `n` counts html blocks across the message's text parts in order. The store keeps the block as a preview
|
|
7
|
+
// artifact (ArtifactStore.savePreview).
|
|
8
|
+
import type { TimelinePage } from "../../protocol"
|
|
9
|
+
import { type MdBlock, parseMarkdown } from "../../../packages/protocol/src/markdown.ts"
|
|
10
|
+
|
|
11
|
+
export const PREVIEW_LIMITS = {
|
|
12
|
+
/** Bytes of one previewed HTML block (413 above). */
|
|
13
|
+
htmlBytes: 2 * 1024 * 1024,
|
|
14
|
+
/**
|
|
15
|
+
* Timeline rows per page read (small, so a page of long replies stays under the spoke frame limit), and pages
|
|
16
|
+
* read before giving up on finding the message.
|
|
17
|
+
*/
|
|
18
|
+
pageRows: 50,
|
|
19
|
+
pages: 40,
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
const HTML_LANGS = new Set(["html", "htm"])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The fenced html/htm blocks of one text in document order (depth first through quotes and lists), parsed by the
|
|
26
|
+
* console's own Markdown parser: exactly the code blocks the console shows, with the same contents. The whole text
|
|
27
|
+
* is parsed (block numbers count from its start, so it cannot stop early): that parser is linear-time on adversarial
|
|
28
|
+
* text (test/web-markdown-perf.test.ts) and keeps blocks nested deeper than its MAX_BLOCK_DEPTH flat, as the console
|
|
29
|
+
* does.
|
|
30
|
+
*/
|
|
31
|
+
export function htmlBlocks(text: string): string[] {
|
|
32
|
+
const out: string[] = []
|
|
33
|
+
const walk = (blocks: readonly MdBlock[]): void => {
|
|
34
|
+
for (const b of blocks) {
|
|
35
|
+
if (b.type === "code" && HTML_LANGS.has(b.lang.toLowerCase())) out.push(b.text)
|
|
36
|
+
else if (b.type === "quote") walk(b.blocks)
|
|
37
|
+
else if (b.type === "list") for (const item of b.items) walk(item.blocks)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
walk(parseMarkdown(text))
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A newest-first timeline page request (op "timeline", order desc). */
|
|
45
|
+
export type TimelineReader = (page: { readonly limit: number; readonly before?: string }) => Promise<TimelinePage>
|
|
46
|
+
|
|
47
|
+
const record = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The texts of the message's assistant text rows in order. Pages are read newest first until the message's rows
|
|
51
|
+
* are complete, at most PREVIEW_LIMITS.pages of them: a message older than that is "too_old" (not looked up, so a
|
|
52
|
+
* Preview never makes the spoke read a whole long history), one missing from the whole timeline "not_found".
|
|
53
|
+
*/
|
|
54
|
+
export async function messageTexts(read: TimelineReader, messageID: string): Promise<{ texts: string[] } | { error: "not_found" | "too_old" }> {
|
|
55
|
+
const rows: Array<{ text: string; order: number }> = []
|
|
56
|
+
let found = false
|
|
57
|
+
let exhausted = false
|
|
58
|
+
let complete = false
|
|
59
|
+
let before: string | undefined
|
|
60
|
+
for (let page = 0; page < PREVIEW_LIMITS.pages; page++) {
|
|
61
|
+
const result = await read({ limit: PREVIEW_LIMITS.pageRows, ...(before !== undefined ? { before } : {}) })
|
|
62
|
+
const items = Array.isArray(result?.items) ? result.items.map(record) : []
|
|
63
|
+
let mine = 0
|
|
64
|
+
items.forEach((item, index) => {
|
|
65
|
+
if (item.messageID !== messageID || item.kind === "message") return
|
|
66
|
+
mine++
|
|
67
|
+
if (item.kind === "assistant" && typeof item.text === "string") rows.push({ text: item.text, order: typeof item.pos === "number" ? item.pos : page * -1e6 + index })
|
|
68
|
+
})
|
|
69
|
+
if (mine > 0) found = true
|
|
70
|
+
// The message's rows are contiguous: done once a page holds rows before them, or there is nothing older.
|
|
71
|
+
const first = items[0]
|
|
72
|
+
if (typeof result?.older !== "string" || !result.older) {
|
|
73
|
+
exhausted = true
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
if (found && first?.messageID !== messageID) {
|
|
77
|
+
complete = true
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
before = result.older
|
|
81
|
+
}
|
|
82
|
+
if (!found) return { error: exhausted ? "not_found" : "too_old" }
|
|
83
|
+
// The cap cut the message's own rows: its block numbering would be wrong.
|
|
84
|
+
if (!complete && !exhausted) return { error: "too_old" }
|
|
85
|
+
return { texts: rows.sort((a, b) => a.order - b.order).map((r) => r.text) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The `block`-th html block across the texts: its bytes, or why not (no such block, larger than `maxBytes`). */
|
|
89
|
+
export function pickHtmlBlock(texts: readonly string[], block: number, maxBytes: number = PREVIEW_LIMITS.htmlBytes): { html: Uint8Array } | { error: "not_found" | "too_large" } {
|
|
90
|
+
const html = texts.flatMap(htmlBlocks)[block]
|
|
91
|
+
if (html === undefined) return { error: "not_found" }
|
|
92
|
+
const bytes = new TextEncoder().encode(html)
|
|
93
|
+
return bytes.byteLength > maxBytes ? { error: "too_large" } : { html: bytes }
|
|
94
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The artifact transfer API (HWA-21; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts),
|
|
2
2
|
// under /api/agent with the agent error envelope:
|
|
3
3
|
//
|
|
4
|
-
// GET /artifacts the caller's library (limit, cursor, state, workspace)
|
|
4
|
+
// GET /artifacts the caller's library (limit, cursor, state, workspace, origin)
|
|
5
5
|
// POST /artifacts create with the first revision (Idempotency-Key)
|
|
6
6
|
// POST /artifacts/blobs stage raw bytes; answers a BlobRef to reference
|
|
7
7
|
// GET /artifacts/:id one artifact
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// DELETE /artifacts/:id irreversible {expectedRevision}
|
|
10
10
|
// PUT /artifacts/:id/visibility {expectedRevision, visibility, workspaceID?}
|
|
11
11
|
// POST /artifacts/:id/archive | /unarchive {expectedRevision}
|
|
12
|
+
// POST /artifacts/:id/keep {expectedRevision}: a preview becomes a normal artifact
|
|
12
13
|
// GET /artifacts/:id/revisions newest first (limit, cursor)
|
|
13
14
|
// POST /artifacts/:id/revisions {baseRevisionID, blobs|upload, entry?} (Idempotency-Key)
|
|
14
15
|
// GET /artifacts/:id/revisions/:rev one revision
|
|
@@ -227,11 +228,15 @@ export function artifactRoutes(deps: ArtifactRouteDeps): AgentRoute[] {
|
|
|
227
228
|
const limit = intParam(url, "limit")
|
|
228
229
|
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
229
230
|
const workspaceID = url.searchParams.get("workspace") ?? undefined
|
|
231
|
+
// Previews (session media §3) are listed only when asked for: origin=preview (only them) or origin=all.
|
|
232
|
+
const origin = url.searchParams.get("origin") ?? undefined
|
|
233
|
+
if (origin !== undefined && origin !== "preview" && origin !== "all") throw invalid("origin must be preview or all")
|
|
230
234
|
const page = await store().list(user.id, principalOf(actor), {
|
|
231
235
|
...(limit !== undefined ? { limit } : {}),
|
|
232
236
|
...(cursor ? { cursor } : {}),
|
|
233
237
|
...(state ? { state: state as RecordState } : {}),
|
|
234
238
|
...(workspaceID ? { workspaceID } : {}),
|
|
239
|
+
...(origin ? { origin: origin as "preview" | "all" } : {}),
|
|
235
240
|
})
|
|
236
241
|
return json(page)
|
|
237
242
|
}),
|
|
@@ -318,6 +323,12 @@ export function artifactRoutes(deps: ArtifactRouteDeps): AgentRoute[] {
|
|
|
318
323
|
}),
|
|
319
324
|
),
|
|
320
325
|
|
|
326
|
+
route("POST", `${P}/:id/keep`, MANAGE, async ({ user, actor, params, request }) => {
|
|
327
|
+
const body = await readJson(request, 4096)
|
|
328
|
+
onlyKeys(body, new Set(["expectedRevision"]), "The body")
|
|
329
|
+
return json({ artifact: await store().keep(user.id, principalOf(actor), params.id!, expectedRevision(body)) })
|
|
330
|
+
}),
|
|
331
|
+
|
|
321
332
|
route("GET", `${P}/:id/revisions`, READ, async ({ user, actor, params, url }) => {
|
|
322
333
|
const limit = intParam(url, "limit")
|
|
323
334
|
const cursor = url.searchParams.get("cursor") ?? undefined
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
//
|
|
31
31
|
// Lifecycle: archive is reversible and keeps everything; delete is irreversible (tombstone first, so a crash half
|
|
32
32
|
// way is finished by recover()); archived artifacts are purged only when `archivedRetentionMs` is set (default:
|
|
33
|
-
// kept until deleted).
|
|
34
|
-
|
|
33
|
+
// kept until deleted). Previews (origin "preview", session media §3) have their own retention in purgeArchived:
|
|
34
|
+
// archived `previewArchiveAfterMs` after their last view (viewedAt, set when a host capability is minted) or
|
|
35
|
+
// creation, purged `previewPurgeAfterMs` after that. Keep clears the origin, which ends it.
|
|
36
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
35
37
|
import {
|
|
36
38
|
ARTIFACT_LIMITS,
|
|
37
39
|
ARTIFACT_MEDIA_TYPES,
|
|
@@ -41,10 +43,13 @@ import {
|
|
|
41
43
|
type IdKind,
|
|
42
44
|
isContractID,
|
|
43
45
|
isExpectedRevision,
|
|
46
|
+
provenanceProblem,
|
|
44
47
|
LIST_LIMIT_DEFAULT,
|
|
45
48
|
LIST_LIMIT_MAX,
|
|
46
49
|
type Artifact,
|
|
47
50
|
type ArtifactKind,
|
|
51
|
+
type ArtifactOrigin,
|
|
52
|
+
type ArtifactProvenance,
|
|
48
53
|
type ArtifactRevision,
|
|
49
54
|
type ArtifactVisibility,
|
|
50
55
|
type BlobRef,
|
|
@@ -64,6 +69,11 @@ export const UPLOADS = "artifact-uploads"
|
|
|
64
69
|
export const revisionsCollection = (artifactID: string): string => `artifact-revisions.${artifactID}`
|
|
65
70
|
/** How long an upload may run, and an unreferenced blob stays, before recovery treats it as abandoned. */
|
|
66
71
|
export const ARTIFACT_GRACE_MS = 15 * 60_000
|
|
72
|
+
/** Preview retention defaults: archived this long after the last view (or creation), then purged this long later. */
|
|
73
|
+
export const PREVIEW_ARCHIVE_AFTER_MS = 24 * 60 * 60_000
|
|
74
|
+
export const PREVIEW_PURGE_AFTER_MS = 7 * 24 * 60 * 60_000
|
|
75
|
+
/** The single file of a preview revision. */
|
|
76
|
+
export const PREVIEW_ENTRY = "index.html"
|
|
67
77
|
/** CAS attempts on the artifact record when unrelated changes race a commit. */
|
|
68
78
|
const ATTEMPTS = 16
|
|
69
79
|
|
|
@@ -98,8 +108,12 @@ export interface ArtifactStoreOptions {
|
|
|
98
108
|
/** Tighter limits than ARTIFACT_LIMITS (never looser for blobBytes). */
|
|
99
109
|
readonly limits?: Partial<ArtifactLimits>
|
|
100
110
|
readonly graceMs?: number
|
|
101
|
-
/** Purge archived artifacts this long after archiving (default: never). */
|
|
111
|
+
/** Purge archived artifacts this long after archiving (default: never). Previews follow their own retention. */
|
|
102
112
|
readonly archivedRetentionMs?: number
|
|
113
|
+
/** Archive a preview this long after its last view, or its creation when never viewed (default 24 h). */
|
|
114
|
+
readonly previewArchiveAfterMs?: number
|
|
115
|
+
/** Purge an archived preview this long after it was archived (default 7 days). */
|
|
116
|
+
readonly previewPurgeAfterMs?: number
|
|
103
117
|
readonly onEvent?: (userID: string, event: ArtifactEvent) => void
|
|
104
118
|
readonly newID?: (kind: IdKind) => string
|
|
105
119
|
}
|
|
@@ -123,6 +137,9 @@ export interface ArtifactInput {
|
|
|
123
137
|
readonly workspaceID?: string
|
|
124
138
|
readonly tags?: readonly string[]
|
|
125
139
|
readonly metadata?: Readonly<Record<string, string>>
|
|
140
|
+
/** Internal (savePreview): the routes never pass these. */
|
|
141
|
+
readonly origin?: ArtifactOrigin
|
|
142
|
+
readonly provenance?: ArtifactProvenance
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
export interface ArtifactUpdate {
|
|
@@ -139,6 +156,32 @@ export interface ArtifactListOptions {
|
|
|
139
156
|
/** Default "active". */
|
|
140
157
|
readonly state?: RecordState
|
|
141
158
|
readonly workspaceID?: string
|
|
159
|
+
/** Default: normal artifacts only. "preview": previews only; "all": both. */
|
|
160
|
+
readonly origin?: ArtifactOrigin | "all"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The files of a preview revision and its entry (one of their paths). */
|
|
164
|
+
export interface PreviewBundleContent {
|
|
165
|
+
readonly entry: string
|
|
166
|
+
readonly files: ReadonlyArray<{ readonly path: string; readonly mediaType: string; readonly body: Uint8Array }>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A stored copy of a provenance: only its known fields. */
|
|
170
|
+
const provenanceCopy = (p: ArtifactProvenance): ArtifactProvenance => ({
|
|
171
|
+
sessionID: p.sessionID,
|
|
172
|
+
messageID: p.messageID,
|
|
173
|
+
...(p.block !== undefined ? { block: p.block } : {}),
|
|
174
|
+
...(p.path !== undefined ? { path: p.path } : {}),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/** "Preview: <file name>" for a page path, cut to the title limit. */
|
|
178
|
+
const previewTitle = (path: string): string => `Preview: ${path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path}`.slice(0, 120)
|
|
179
|
+
|
|
180
|
+
export interface SavedPreview {
|
|
181
|
+
readonly artifact: Artifact
|
|
182
|
+
readonly revision: ArtifactRevision
|
|
183
|
+
/** The content was unchanged: the latest revision was reused. */
|
|
184
|
+
readonly reused: boolean
|
|
142
185
|
}
|
|
143
186
|
|
|
144
187
|
export interface RecoveryReport {
|
|
@@ -154,6 +197,8 @@ interface StoredArtifact {
|
|
|
154
197
|
readonly head: number
|
|
155
198
|
readonly archivedAt?: number
|
|
156
199
|
readonly deletingAt?: number
|
|
200
|
+
/** Previews: the last time a host capability was minted for it (retention counts from it). */
|
|
201
|
+
readonly viewedAt?: number
|
|
157
202
|
}
|
|
158
203
|
|
|
159
204
|
interface Holder {
|
|
@@ -215,6 +260,10 @@ export class ArtifactStore {
|
|
|
215
260
|
private readonly limits: ArtifactLimits
|
|
216
261
|
private readonly graceMs: number
|
|
217
262
|
private readonly archivedRetentionMs: number | undefined
|
|
263
|
+
private readonly previewArchiveAfterMs: number
|
|
264
|
+
private readonly previewPurgeAfterMs: number
|
|
265
|
+
/** savePreview calls in flight per user and block (one process): concurrent presses share one artifact. */
|
|
266
|
+
private readonly previewsInFlight = new Map<string, Promise<SavedPreview>>()
|
|
218
267
|
private readonly onEvent: ((userID: string, event: ArtifactEvent) => void) | undefined
|
|
219
268
|
private readonly newID: (kind: IdKind) => string
|
|
220
269
|
|
|
@@ -228,6 +277,8 @@ export class ArtifactStore {
|
|
|
228
277
|
if (this.limits.blobBytes > ARTIFACT_LIMITS.blobBytes) throw new Error("limits.blobBytes exceeds ARTIFACT_LIMITS.blobBytes")
|
|
229
278
|
this.graceMs = options.graceMs ?? ARTIFACT_GRACE_MS
|
|
230
279
|
this.archivedRetentionMs = options.archivedRetentionMs
|
|
280
|
+
this.previewArchiveAfterMs = options.previewArchiveAfterMs ?? PREVIEW_ARCHIVE_AFTER_MS
|
|
281
|
+
this.previewPurgeAfterMs = options.previewPurgeAfterMs ?? PREVIEW_PURGE_AFTER_MS
|
|
231
282
|
this.onEvent = options.onEvent
|
|
232
283
|
this.newID = options.newID ?? randomID
|
|
233
284
|
}
|
|
@@ -252,6 +303,7 @@ export class ArtifactStore {
|
|
|
252
303
|
.map((entry) => entry.value)
|
|
253
304
|
.filter((s) => s.deletingAt === undefined && s.artifact.state === state)
|
|
254
305
|
.filter((s) => options.workspaceID === undefined || s.artifact.workspaceID === options.workspaceID)
|
|
306
|
+
.filter((s) => options.origin === "all" || s.artifact.origin === options.origin)
|
|
255
307
|
.map((s) => s.artifact)
|
|
256
308
|
.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
257
309
|
.filter((a) => !after || a.updatedAt < after.updatedAt || (a.updatedAt === after.updatedAt && a.id > after.id))
|
|
@@ -332,6 +384,8 @@ export class ArtifactStore {
|
|
|
332
384
|
latestRevisionID: rev.id,
|
|
333
385
|
...(input.tags !== undefined ? { tags: [...input.tags] } : {}),
|
|
334
386
|
...(input.metadata !== undefined ? { metadata: { ...input.metadata } } : {}),
|
|
387
|
+
...(input.origin !== undefined ? { origin: input.origin } : {}),
|
|
388
|
+
...(input.provenance !== undefined ? { provenance: provenanceCopy(input.provenance) } : {}),
|
|
335
389
|
createdAt: at,
|
|
336
390
|
updatedAt: at,
|
|
337
391
|
}
|
|
@@ -428,14 +482,20 @@ export class ArtifactStore {
|
|
|
428
482
|
await this.finishDelete(userID, id)
|
|
429
483
|
}
|
|
430
484
|
|
|
431
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Retention, for one user: archives previews not viewed for previewArchiveAfterMs, then purges archived previews
|
|
487
|
+
* older than previewPurgeAfterMs and other archived artifacts older than archivedRetentionMs (never when unset);
|
|
488
|
+
* resolves with the purged IDs.
|
|
489
|
+
*/
|
|
432
490
|
async purgeArchived(userID: string): Promise<string[]> {
|
|
433
|
-
|
|
434
|
-
const
|
|
491
|
+
await this.archiveStalePreviews(userID)
|
|
492
|
+
const now = this.now()
|
|
493
|
+
const retention = (a: Artifact): number | undefined => (a.origin === "preview" ? this.previewPurgeAfterMs : this.archivedRetentionMs)
|
|
435
494
|
const purged: string[] = []
|
|
436
495
|
for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
|
|
437
496
|
const s = entry.value
|
|
438
|
-
|
|
497
|
+
const keep = retention(s.artifact)
|
|
498
|
+
if (keep === undefined || s.deletingAt !== undefined || s.artifact.state !== "archived" || s.archivedAt === undefined || s.archivedAt > now - keep) continue
|
|
439
499
|
const marked = await this.state.put<StoredArtifact>(userID, ARTIFACTS, entry.key, { ...s, deletingAt: this.now() }, { ifVersion: entry.version })
|
|
440
500
|
if (!marked.ok) continue
|
|
441
501
|
await this.finishDelete(userID, entry.key)
|
|
@@ -444,6 +504,134 @@ export class ArtifactStore {
|
|
|
444
504
|
return purged
|
|
445
505
|
}
|
|
446
506
|
|
|
507
|
+
// ---- previews (session media §3) ------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Stores an HTML preview for the user: a private html artifact with origin "preview" and its source as provenance.
|
|
511
|
+
* A code block (`block`) is one index.html file; a local page (`path`, session media §3 4b) is its bundle (files and
|
|
512
|
+
* entry). The same source again reuses the latest revision when the content is unchanged (the same files with the
|
|
513
|
+
* same hashes and types, in any order, and the same entry) and adds a revision otherwise; an archived preview is
|
|
514
|
+
* restored. A preview counts as viewed.
|
|
515
|
+
*/
|
|
516
|
+
savePreview(userID: string, source: ArtifactProvenance, content: string | Uint8Array | PreviewBundleContent, options: { readonly title?: string } = {}): Promise<SavedPreview> {
|
|
517
|
+
const key = JSON.stringify([userID, source.sessionID, source.messageID, source.block ?? null, source.path ?? null])
|
|
518
|
+
const running = this.previewsInFlight.get(key)
|
|
519
|
+
const bundle: PreviewBundleContent =
|
|
520
|
+
typeof content === "string" || content instanceof Uint8Array
|
|
521
|
+
? { entry: PREVIEW_ENTRY, files: [{ path: PREVIEW_ENTRY, mediaType: "text/html", body: typeof content === "string" ? new TextEncoder().encode(content) : content }] }
|
|
522
|
+
: content
|
|
523
|
+
const run = (running ?? Promise.resolve())
|
|
524
|
+
.catch(() => {})
|
|
525
|
+
.then(() => this.storePreview(userID, source, bundle, options.title))
|
|
526
|
+
const tracked = run.finally(() => {
|
|
527
|
+
if (this.previewsInFlight.get(key) === tracked) this.previewsInFlight.delete(key)
|
|
528
|
+
})
|
|
529
|
+
this.previewsInFlight.set(key, tracked)
|
|
530
|
+
return tracked
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Records a view of a preview (a host capability was minted for it): its retention restarts, and an archived
|
|
535
|
+
* preview is restored, as pressing Preview again does. Anything else is left alone.
|
|
536
|
+
*/
|
|
537
|
+
async markViewed(userID: string, artifactID: string): Promise<void> {
|
|
538
|
+
const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, artifactID)
|
|
539
|
+
if (!entry || entry.value.deletingAt !== undefined || entry.value.artifact.origin !== "preview") return
|
|
540
|
+
await this.mutateStored(userID, artifactID, (s) => this.viewed(s), (s) => s.artifact.state === "archived").catch((error: unknown) => {
|
|
541
|
+
if (!(error instanceof AgentError && error.code === "not_found")) throw error
|
|
542
|
+
})
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Keep: a preview becomes a normal artifact (origin cleared; provenance stays). A normal artifact is unchanged. */
|
|
546
|
+
async keep(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
|
|
547
|
+
const { stored } = await this.load(userID, principal, id, "manage")
|
|
548
|
+
if (stored.artifact.origin === undefined) {
|
|
549
|
+
// Nothing to change, but a stale expectedRevision is a conflict as for every other change.
|
|
550
|
+
if (!isExpectedRevision(expectedRevision)) throw invalid("expectedRevision must be a non-negative integer")
|
|
551
|
+
if (stored.artifact.revision !== expectedRevision) throw conflict("expectedRevision is not the current revision", stored)
|
|
552
|
+
return stored.artifact
|
|
553
|
+
}
|
|
554
|
+
return this.mutate(userID, principal, id, expectedRevision, async (s) => {
|
|
555
|
+
const { origin: _, ...artifact } = s.artifact
|
|
556
|
+
const { viewedAt: _v, ...rest } = s
|
|
557
|
+
return { ...rest, artifact: artifact as Artifact }
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async storePreview(userID: string, source: ArtifactProvenance, bundle: PreviewBundleContent, title: string | undefined): Promise<SavedPreview> {
|
|
562
|
+
const defaultTitle = source.path !== undefined ? previewTitle(source.path) : `HTML preview ${(source.block ?? 0) + 1}`
|
|
563
|
+
const problem = provenanceProblem(source) ?? artifactInputProblem({ title: title ?? defaultTitle, kind: "html", origin: "preview", provenance: source })
|
|
564
|
+
if (problem) throw invalid(problem)
|
|
565
|
+
const files: RevisionFile[] = bundle.files.map((f) => ({ body: f.body, mediaType: f.mediaType, path: f.path }))
|
|
566
|
+
const hashes = new Map(bundle.files.map((f) => [f.path, `${f.mediaType} sha256:${createHash("sha256").update(f.body).digest("hex")}`]))
|
|
567
|
+
const found = (await this.state.list<StoredArtifact>(userID, ARTIFACTS)).find(
|
|
568
|
+
(e) =>
|
|
569
|
+
e.value.deletingAt === undefined &&
|
|
570
|
+
e.value.artifact.origin === "preview" &&
|
|
571
|
+
e.value.artifact.provenance?.sessionID === source.sessionID &&
|
|
572
|
+
e.value.artifact.provenance.messageID === source.messageID &&
|
|
573
|
+
e.value.artifact.provenance.block === source.block &&
|
|
574
|
+
e.value.artifact.provenance.path === source.path,
|
|
575
|
+
)
|
|
576
|
+
if (!found) {
|
|
577
|
+
const input: ArtifactInput = { title: title ?? defaultTitle, kind: "html", origin: "preview", provenance: source }
|
|
578
|
+
const created = await this.create(userID, { kind: "user" }, input, { files, entry: bundle.entry })
|
|
579
|
+
return { ...created, reused: false }
|
|
580
|
+
}
|
|
581
|
+
const id = found.key
|
|
582
|
+
// Viewed now (and restored when archived), so retention never takes it while the console opens it.
|
|
583
|
+
let artifact = await this.mutateStored(userID, id, (s) => this.viewed(s), (s) => s.artifact.state === "archived")
|
|
584
|
+
const latest = (await this.visibleRevisions(userID, (await this.state.get<StoredArtifact>(userID, ARTIFACTS, id))!.value))[0]
|
|
585
|
+
const same =
|
|
586
|
+
latest !== undefined &&
|
|
587
|
+
latest.entry === bundle.entry &&
|
|
588
|
+
latest.blobs.length === hashes.size &&
|
|
589
|
+
latest.blobs.every((b) => b.path !== undefined && hashes.get(b.path) === `${b.mediaType} ${b.hash}`)
|
|
590
|
+
if (same) return { artifact, revision: latest, reused: true }
|
|
591
|
+
const revised = await this.revise(userID, { kind: "user" }, id, { files, entry: bundle.entry, baseRevisionID: artifact.latestRevisionID })
|
|
592
|
+
artifact = revised.artifact
|
|
593
|
+
return { artifact, revision: revised.revision, reused: false }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A preview's record after a view: active again (when archived) with its retention clock restarted. */
|
|
597
|
+
private viewed(s: StoredArtifact): StoredArtifact {
|
|
598
|
+
if (s.artifact.origin !== "preview") return s
|
|
599
|
+
const { archivedAt: _, ...rest } = s
|
|
600
|
+
return { ...rest, viewedAt: this.now(), artifact: { ...s.artifact, state: "active" } }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* A system change of a stored record (CAS-retried, no principal): `bump` says whether it is a visible change
|
|
605
|
+
* (revision and updatedAt move, as for a user's change; archive events are emitted by the caller).
|
|
606
|
+
*/
|
|
607
|
+
private async mutateStored(userID: string, id: string, change: (s: StoredArtifact) => StoredArtifact, bump: (s: StoredArtifact) => boolean): Promise<Artifact> {
|
|
608
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
609
|
+
const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
|
|
610
|
+
if (!entry || entry.value.deletingAt !== undefined) throw notFound()
|
|
611
|
+
const visible = bump(entry.value)
|
|
612
|
+
const changed = change(entry.value)
|
|
613
|
+
const next: StoredArtifact = visible ? { ...changed, artifact: { ...changed.artifact, revision: entry.value.artifact.revision + 1, updatedAt: this.now() } } : changed
|
|
614
|
+
if ((await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, next, { ifVersion: entry.version })).ok) {
|
|
615
|
+
if (visible && next.artifact.state !== entry.value.artifact.state) {
|
|
616
|
+
this.emit(userID, { type: "artifact.archived", artifactID: id, kind: next.artifact.kind, revision: next.artifact.revision, state: next.artifact.state })
|
|
617
|
+
}
|
|
618
|
+
return next.artifact
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
throw conflict("The artifact kept changing; retry", undefined)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Archives active previews whose last view (or creation) is older than previewArchiveAfterMs. */
|
|
625
|
+
private async archiveStalePreviews(userID: string): Promise<void> {
|
|
626
|
+
const cutoff = this.now() - this.previewArchiveAfterMs
|
|
627
|
+
for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
|
|
628
|
+
const s = entry.value
|
|
629
|
+
if (s.deletingAt !== undefined || s.artifact.origin !== "preview" || s.artifact.state !== "active" || (s.viewedAt ?? s.artifact.createdAt) > cutoff) continue
|
|
630
|
+
const stale = (c: StoredArtifact) => c.artifact.origin === "preview" && c.artifact.state === "active" && (c.viewedAt ?? c.artifact.createdAt) <= cutoff
|
|
631
|
+
await this.mutateStored(userID, entry.key, (c) => (stale(c) ? { ...c, archivedAt: this.now(), artifact: { ...c.artifact, state: "archived" } } : c), stale).catch(() => {})
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
447
635
|
// ---- recovery -------------------------------------------------------------------------------------------
|
|
448
636
|
|
|
449
637
|
/**
|
|
@@ -705,7 +893,9 @@ export class ArtifactStore {
|
|
|
705
893
|
private async setState(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number, to: RecordState): Promise<Artifact> {
|
|
706
894
|
const artifact = await this.mutate(userID, principal, id, expectedRevision, async (s) => {
|
|
707
895
|
const { archivedAt: _, ...rest } = s
|
|
708
|
-
|
|
896
|
+
// Unarchiving a preview restarts its retention, so the next maintenance pass does not archive it again.
|
|
897
|
+
const viewed = to === "active" && s.artifact.origin === "preview" ? { viewedAt: this.now() } : {}
|
|
898
|
+
return { ...rest, ...viewed, ...(to === "archived" ? { archivedAt: this.now() } : {}), artifact: { ...s.artifact, state: to } }
|
|
709
899
|
})
|
|
710
900
|
this.emit(userID, { type: "artifact.archived", artifactID: id, kind: artifact.kind, revision: artifact.revision, state: to })
|
|
711
901
|
return artifact
|
package/src/central/config.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolv
|
|
|
11
11
|
import { updateSpeech, type StoredSpeech } from "./speech/settings"
|
|
12
12
|
import { checkReleaseBase } from "./install/render"
|
|
13
13
|
import { cspMode, type CspMode } from "./csp"
|
|
14
|
+
import { parseDenyCidrs } from "./fetcher"
|
|
14
15
|
|
|
15
16
|
export const GOOGLE_ISSUER = "https://accounts.google.com"
|
|
16
17
|
|
|
@@ -77,6 +78,10 @@ export interface CentralConfig {
|
|
|
77
78
|
readonly releaseBase?: string
|
|
78
79
|
/** Cluster mode (design §9): set only when HUB_DATABASE_URL and HUB_VALKEY_URL (or hub.json cluster) are. */
|
|
79
80
|
readonly cluster?: ClusterConfig
|
|
81
|
+
/** HUB_REMOTE_FETCH=off (only then set, to false): turns off the hub fetching web URLs for the console (/api/media/remote, /api/media/unfurl). */
|
|
82
|
+
readonly remoteFetch?: boolean
|
|
83
|
+
/** HUB_FETCH_DENY_CIDRS: CIDRs the hub's outbound fetcher refuses on top of its built-in ranges (validated). */
|
|
84
|
+
readonly fetchDenyCidrs?: readonly string[]
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/**
|
|
@@ -469,6 +474,28 @@ function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): Clust
|
|
|
469
474
|
return cluster as ClusterConfig
|
|
470
475
|
}
|
|
471
476
|
|
|
477
|
+
const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] as const
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* HUB_REMOTE_FETCH: "on", "off", or unset. Unset means on, unless a proxy is set in the environment (Bun's fetch
|
|
481
|
+
* honours HTTP(S)_PROXY with no per-request opt-out, and a proxy may re-resolve the name, undoing the fetcher's
|
|
482
|
+
* address pinning) and NO_PROXY is not "*": then off, with a startup warning. Throws on any other value.
|
|
483
|
+
*/
|
|
484
|
+
export function remoteFetchSetting(rawEnv: Env): { enabled: boolean; warning?: string } {
|
|
485
|
+
const raw = rawEnv.HUB_REMOTE_FETCH
|
|
486
|
+
const value = raw?.trim().toLowerCase()
|
|
487
|
+
if (value === "on") return { enabled: true }
|
|
488
|
+
if (value === "off") return { enabled: false }
|
|
489
|
+
if (value) throw new Error(`HUB_REMOTE_FETCH must be "on" or "off", got ${JSON.stringify(raw)}`)
|
|
490
|
+
const proxy = PROXY_VARS.find((name) => rawEnv[name]?.trim())
|
|
491
|
+
const noProxy = (rawEnv.NO_PROXY ?? rawEnv.no_proxy)?.trim()
|
|
492
|
+
if (!proxy || noProxy === "*") return { enabled: true }
|
|
493
|
+
return {
|
|
494
|
+
enabled: false,
|
|
495
|
+
warning: `remote images are off (HUB_REMOTE_FETCH): ${proxy} is set, and a proxy could re-resolve hostnames and reach private addresses. Set NO_PROXY=* for the hub process to fetch directly, or HUB_REMOTE_FETCH=on to accept proxying knowingly`,
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
472
499
|
export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
|
|
473
500
|
const env = hubEnv(rawEnv)
|
|
474
501
|
const host = env.HUB_HOST || file.host || "127.0.0.1"
|
|
@@ -486,6 +513,9 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
486
513
|
}
|
|
487
514
|
const auth = authConfig(env, file.auth)
|
|
488
515
|
const cluster = clusterConfig(env, auth, file.cluster)
|
|
516
|
+
const remoteFetch = remoteFetchSetting(env).enabled ? "on" : "off"
|
|
517
|
+
parseDenyCidrs(env.HUB_FETCH_DENY_CIDRS) // throws on an invalid entry: a startup error
|
|
518
|
+
const fetchDenyCidrs = (env.HUB_FETCH_DENY_CIDRS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)
|
|
489
519
|
return {
|
|
490
520
|
host,
|
|
491
521
|
port,
|
|
@@ -508,6 +538,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
508
538
|
...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
|
|
509
539
|
...(releaseBase ? { releaseBase } : {}),
|
|
510
540
|
...(cluster ? { cluster } : {}),
|
|
541
|
+
...(remoteFetch === "off" ? { remoteFetch: false } : {}),
|
|
542
|
+
...(fetchDenyCidrs.length ? { fetchDenyCidrs } : {}),
|
|
511
543
|
}
|
|
512
544
|
}
|
|
513
545
|
|
|
@@ -532,6 +564,8 @@ export function loadCentralConfig(
|
|
|
532
564
|
if (!resolved.HUB_CONFIG) migrateFirst(legacyPaths(configBase(env, home), "hub.json"), file, migrate)
|
|
533
565
|
const { file: hub, warnings } = readHubFile(file, home)
|
|
534
566
|
const config = centralConfig(env, home, hub)
|
|
567
|
+
const remoteFetchWarning = remoteFetchSetting(hubEnv(env)).warning
|
|
568
|
+
if (remoteFetchWarning) warnings.push(remoteFetchWarning)
|
|
535
569
|
if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
|
|
536
570
|
// hub.pid is the spoke's auto-start lock for the legacy location, not state.
|
|
537
571
|
migrateStateDir(stateLegacyPaths(env, home), config.stateDir, { ...migrate, skip: ["hub.pid"] })
|