@huynle/hub 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Warms OpenCode servers so their hub plugin loads (RP-10, feedback qe6lpzxz #4; RF-04). OpenCode 2.x loads plugins
|
|
2
|
+
// per location, on the first request that names one, and its plugin API has no server-start hook: a freshly started
|
|
3
|
+
// server (`opencode service restart`, a login item, a foreground `opencode serve`) has no hub spoke, so its machine
|
|
4
|
+
// shows offline and host launches cannot be placed until a client opens a project; and OpenCode unloads a location
|
|
5
|
+
// after an hour without session activity (its experimental.location_idle_timeout), which takes the plugin with it.
|
|
6
|
+
// The node daemon asks each server for cheap location reads on every tick: GET /api/agent without a location (the
|
|
7
|
+
// server's default location), then GET /api/agent?location[directory]=<dir> for each project directory to keep
|
|
8
|
+
// loaded: node.json's list, else the projects of the server's recent root sessions under the node's workspace roots
|
|
9
|
+
// (at most WARM_DIRECTORIES_MAX), so a foreground server's spoke reports those projects' idle sessions.
|
|
10
|
+
// The background service is found through its registration file (readServiceRegistration: no request; not
|
|
11
|
+
// @opencode/client's Service.discover, which probes whatever URL the file names with the password and follows
|
|
12
|
+
// redirects); a foreground server through node.json `opencode.servers` (serverEndpoint: its URL and a password file).
|
|
13
|
+
// Security: only a literal loopback URL (127.0.0.1, [::1]; localhost is asked as 127.0.0.1, no resolver) without
|
|
14
|
+
// user info is ever asked, never across a redirect, so a password never leaves this machine; a password file must be
|
|
15
|
+
// a private regular file (no group/other access, not a link) and its content is never logged; logs carry the URL's
|
|
16
|
+
// origin only. Logs on changes only.
|
|
17
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
|
|
20
|
+
|
|
21
|
+
/** How often the daemon asks (a restarted server is warmed within this). */
|
|
22
|
+
export const WARM_INTERVAL_MS = 30_000
|
|
23
|
+
/** Per-request timeout (node.json opencode.timeoutSeconds overrides it): long enough for a busy machine's first project load. */
|
|
24
|
+
export const WARM_TIMEOUT_MS = 30_000
|
|
25
|
+
/** Project locations warmed per server and tick, besides its default location. */
|
|
26
|
+
export const WARM_DIRECTORIES_MAX = 8
|
|
27
|
+
/** Automatic project locations: sessions updated within this many days, from one page of this many. */
|
|
28
|
+
const SESSION_DAYS = 7
|
|
29
|
+
const SESSION_PAGE = 100
|
|
30
|
+
/** Loopback hosts asked as they are (after URL normalization); localhost is rewritten to 127.0.0.1. */
|
|
31
|
+
const LOOPBACK = new Set(["127.0.0.1", "[::1]"])
|
|
32
|
+
const PASSWORD_FILE_MAX = 4096
|
|
33
|
+
/** Largest service.json read (OpenCode's is a few hundred bytes). */
|
|
34
|
+
const REGISTRATION_MAX = 64 * 1024
|
|
35
|
+
|
|
36
|
+
/** A local OpenCode server (the service's registration, or a node.json server with its password). */
|
|
37
|
+
export interface WarmEndpoint {
|
|
38
|
+
readonly url: string
|
|
39
|
+
readonly auth?: { readonly type: "basic"; readonly username: string; readonly password: string } | undefined
|
|
40
|
+
/** Why the endpoint cannot be used (e.g. its password file); the tick fails with it and sends nothing. */
|
|
41
|
+
readonly problem?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OpenCodeWarmerOptions {
|
|
45
|
+
/** The running server, or undefined when there is none (the service's registration file, or a node.json server). */
|
|
46
|
+
readonly discover: () => Promise<WarmEndpoint | undefined>
|
|
47
|
+
readonly log: (line: string) => void
|
|
48
|
+
readonly intervalMs?: number
|
|
49
|
+
readonly timeoutMs?: number
|
|
50
|
+
readonly fetch?: typeof fetch
|
|
51
|
+
/** What is warmed, in log lines (default "the OpenCode service"). */
|
|
52
|
+
readonly name?: string
|
|
53
|
+
/** Project directories to keep loaded besides the default location; replaces the automatic ones ([]: none). */
|
|
54
|
+
readonly directories?: readonly string[]
|
|
55
|
+
/** Automatic project directories (when `directories` is absent): recent sessions' projects under these roots. */
|
|
56
|
+
readonly roots?: () => readonly string[]
|
|
57
|
+
/** Whether a directory exists (default: stat); missing ones are not asked for. */
|
|
58
|
+
readonly isDirectory?: (directory: string) => boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type WarmOutcome = "warmed" | "none" | "failed"
|
|
62
|
+
|
|
63
|
+
const isDirectory = (directory: string) => {
|
|
64
|
+
try {
|
|
65
|
+
return statSync(directory).isDirectory()
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const trimSlash = (dir: string) => dir.replace(/\/+$/, "") || "/"
|
|
72
|
+
const within = (dir: string, root: string) => {
|
|
73
|
+
const r = trimSlash(root)
|
|
74
|
+
return dir === r || dir.startsWith(r === "/" ? "/" : `${r}/`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A directory the server reported, normalized: absolute, without `.` or `..` segments (refused, not resolved: a
|
|
79
|
+
* server's `/roots/../etc` must not pass a prefix check), trailing slashes dropped; undefined otherwise.
|
|
80
|
+
*/
|
|
81
|
+
function normalDirectory(dir: string): string | undefined {
|
|
82
|
+
if (typeof dir !== "string" || !path.isAbsolute(dir) || dir.split("/").some((part) => part === "." || part === "..")) return undefined
|
|
83
|
+
return path.resolve(dir)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class OpenCodeWarmer {
|
|
87
|
+
/** What was last logged per topic, so only changes are logged. */
|
|
88
|
+
private readonly last = new Map<string, string>()
|
|
89
|
+
private timer?: ReturnType<typeof setTimeout>
|
|
90
|
+
private warmed: string[] = []
|
|
91
|
+
|
|
92
|
+
constructor(private readonly options: OpenCodeWarmerOptions) {}
|
|
93
|
+
|
|
94
|
+
/** The project directories the last tick loaded (besides the default location). */
|
|
95
|
+
warmedDirectories(): readonly string[] {
|
|
96
|
+
return this.warmed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One discovery and, when a server runs, its location reads. */
|
|
100
|
+
async tick(): Promise<WarmOutcome> {
|
|
101
|
+
this.warmed = []
|
|
102
|
+
let endpoint: WarmEndpoint | undefined
|
|
103
|
+
try {
|
|
104
|
+
endpoint = await this.options.discover()
|
|
105
|
+
} catch {
|
|
106
|
+
endpoint = undefined
|
|
107
|
+
}
|
|
108
|
+
if (!endpoint) return "none"
|
|
109
|
+
let registered: URL
|
|
110
|
+
try {
|
|
111
|
+
registered = new URL(endpoint.url)
|
|
112
|
+
} catch {
|
|
113
|
+
return this.report("the registered URL", "an invalid URL")
|
|
114
|
+
}
|
|
115
|
+
const label = registered.origin
|
|
116
|
+
if (registered.username || registered.password) return this.report(label, "a URL with user info")
|
|
117
|
+
if (registered.protocol !== "http:" && registered.protocol !== "https:") return this.report(label, "not an http URL")
|
|
118
|
+
const host = registered.hostname === "localhost" ? "127.0.0.1" : registered.hostname
|
|
119
|
+
if (!LOOPBACK.has(host)) return this.report(label, "not a loopback URL")
|
|
120
|
+
if (endpoint.problem) return this.report(label, endpoint.problem)
|
|
121
|
+
const base = `${registered.protocol}//${host}${registered.port ? `:${registered.port}` : ""}`
|
|
122
|
+
const headers: Record<string, string> = endpoint.auth ? { authorization: `Basic ${btoa(`${endpoint.auth.username}:${endpoint.auth.password}`)}` } : {}
|
|
123
|
+
const problem = await this.get(`${base}/api/agent`, headers).then(() => undefined, (cause: Error) => cause.message)
|
|
124
|
+
if (problem) return this.report(label, problem)
|
|
125
|
+
this.report(label)
|
|
126
|
+
await this.warmDirectories(base, headers, label)
|
|
127
|
+
return "warmed"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Ticks now and every intervalMs until `signal` aborts. */
|
|
131
|
+
start(signal: AbortSignal): void {
|
|
132
|
+
const loop = async () => {
|
|
133
|
+
if (signal.aborted) return
|
|
134
|
+
await this.tick()
|
|
135
|
+
if (!signal.aborted) this.timer = setTimeout(loop, this.options.intervalMs ?? WARM_INTERVAL_MS)
|
|
136
|
+
}
|
|
137
|
+
signal.addEventListener("abort", () => clearTimeout(this.timer), { once: true })
|
|
138
|
+
void loop()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Loads each project location (listed, else automatic); problems are logged on change, never fatal. */
|
|
142
|
+
private async warmDirectories(base: string, headers: Record<string, string>, label: string): Promise<void> {
|
|
143
|
+
let directories: readonly string[]
|
|
144
|
+
if (this.options.directories) directories = this.options.directories
|
|
145
|
+
else if (this.options.roots) {
|
|
146
|
+
try {
|
|
147
|
+
directories = await this.sessionProjects(base, headers, this.options.roots())
|
|
148
|
+
this.note("list", label)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
this.note("list", label, `could not list the sessions of ${this.name()} at ${label} (${(cause as Error).message}); warming its default location only`)
|
|
151
|
+
directories = []
|
|
152
|
+
}
|
|
153
|
+
} else directories = []
|
|
154
|
+
const exists = this.options.isDirectory ?? isDirectory
|
|
155
|
+
const failed: string[] = []
|
|
156
|
+
for (const directory of directories.slice(0, WARM_DIRECTORIES_MAX)) {
|
|
157
|
+
if (!exists(directory)) continue
|
|
158
|
+
try {
|
|
159
|
+
await this.get(`${base}/api/agent?${new URLSearchParams({ "location[directory]": directory })}`, headers)
|
|
160
|
+
this.warmed.push(directory)
|
|
161
|
+
} catch (cause) {
|
|
162
|
+
failed.push(`${directory} (${(cause as Error).message})`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.note("directories", label, failed.length ? `could not load ${failed.length} project location(s) of ${this.name()} at ${label}: ${failed.join(", ")}` : undefined)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The projects of the server's recent root sessions under `roots`, newest first. */
|
|
169
|
+
private async sessionProjects(base: string, headers: Record<string, string>, roots: readonly string[]): Promise<string[]> {
|
|
170
|
+
if (roots.length === 0) return []
|
|
171
|
+
const client: ListingClient = {
|
|
172
|
+
session: {
|
|
173
|
+
list: async (input) => {
|
|
174
|
+
const query = new URLSearchParams({ limit: String(input.limit ?? SESSION_PAGE), ...(input.cursor ? { cursor: input.cursor } : {}) })
|
|
175
|
+
const page = (await this.json(`${base}/api/session?${query}`, headers)) as { data?: RawSession[]; cursor?: { next?: string | null } }
|
|
176
|
+
if (!page || !Array.isArray(page.data)) throw new Error("not a session list")
|
|
177
|
+
return { data: page.data, cursor: { next: null } }
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
project: { list: async () => (await this.json(`${base}/api/project`, headers)) as RawProject[] },
|
|
181
|
+
}
|
|
182
|
+
const sessions = await listServiceSessions(client, { days: SESSION_DAYS, max: SESSION_PAGE, pageSize: SESSION_PAGE, maxPages: 1 })
|
|
183
|
+
const out: string[] = []
|
|
184
|
+
for (const s of sessions) {
|
|
185
|
+
const project = normalDirectory(s.project)
|
|
186
|
+
if (!project || project === "/" || !roots.some((root) => within(project, path.resolve(root))) || out.includes(project)) continue
|
|
187
|
+
out.push(project)
|
|
188
|
+
if (out.length >= WARM_DIRECTORIES_MAX) break
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async json(url: string, headers: Record<string, string>): Promise<unknown> {
|
|
194
|
+
const response = await this.request(url, headers)
|
|
195
|
+
return response.json()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async get(url: string, headers: Record<string, string>): Promise<void> {
|
|
199
|
+
const response = await this.request(url, headers)
|
|
200
|
+
void response.body?.cancel().catch(() => {})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One request: no redirect followed (a 3xx is a failure), bounded in time. Errors carry no URL or credentials. */
|
|
204
|
+
private async request(url: string, headers: Record<string, string>): Promise<Response> {
|
|
205
|
+
let response: Response
|
|
206
|
+
try {
|
|
207
|
+
response = await (this.options.fetch ?? fetch)(url, { headers, redirect: "manual", signal: AbortSignal.timeout(this.options.timeoutMs ?? WARM_TIMEOUT_MS) })
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw new Error((cause as Error).name === "TimeoutError" ? "timed out" : "unreachable")
|
|
210
|
+
}
|
|
211
|
+
if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
|
212
|
+
void response.body?.cancel().catch(() => {})
|
|
213
|
+
throw new Error(`a redirect (HTTP ${response.status}), not followed`)
|
|
214
|
+
}
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
void response.body?.cancel().catch(() => {})
|
|
217
|
+
throw new Error(`HTTP ${response.status}`)
|
|
218
|
+
}
|
|
219
|
+
return response
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private name(): string {
|
|
223
|
+
return this.options.name ?? "the OpenCode service"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Logs `line` when a topic's state changed (undefined: fine now, logged only as a change back to fine). */
|
|
227
|
+
private note(topic: string, label: string, line?: string): void {
|
|
228
|
+
const state = line ?? `ok ${label}`
|
|
229
|
+
if (this.last.get(topic) === state) return
|
|
230
|
+
const before = this.last.get(topic)
|
|
231
|
+
this.last.set(topic, state)
|
|
232
|
+
if (line) this.options.log(line)
|
|
233
|
+
else if (before !== undefined && !before.startsWith("ok ")) this.options.log(`${topic === "list" ? "listing the sessions" : "loading the project locations"} of ${this.name()} at ${label} works again`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private report(url: string, problem?: string): WarmOutcome {
|
|
237
|
+
const line = problem ? `failed ${url}: ${problem}` : `ok ${url}`
|
|
238
|
+
if (line !== this.last.get("warm")) {
|
|
239
|
+
this.last.set("warm", line)
|
|
240
|
+
this.options.log(problem ? `could not warm ${this.name()} at ${url} (${problem}); retrying` : `warmed ${this.name()} at ${url}: its hub plugin loads, so its spoke connects`)
|
|
241
|
+
}
|
|
242
|
+
return problem ? "failed" : "warmed"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
|
|
248
|
+
* without any request: undefined when the file is missing or malformed, is not this user's own (a symbolic link, not
|
|
249
|
+
* owned by this uid, or writable by group or others), or its process is gone or another user's (EPERM). The URL is
|
|
250
|
+
* passed on as it is; OpenCodeWarmer.tick decides whether it may be asked.
|
|
251
|
+
*/
|
|
252
|
+
export async function readServiceRegistration(file: string, options: { readonly uid?: number } = {}): Promise<WarmEndpoint | undefined> {
|
|
253
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined)
|
|
254
|
+
let info: unknown
|
|
255
|
+
let fd: number | undefined
|
|
256
|
+
try {
|
|
257
|
+
const before = lstatSync(file)
|
|
258
|
+
if (!before.isFile()) return undefined
|
|
259
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
260
|
+
const stat = fstatSync(fd)
|
|
261
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return undefined
|
|
262
|
+
if ((stat.mode & 0o022) !== 0) return undefined
|
|
263
|
+
if (uid !== undefined && stat.uid !== uid) return undefined
|
|
264
|
+
if (stat.size > REGISTRATION_MAX) return undefined
|
|
265
|
+
// Read through the descriptor that was checked (no second open by path).
|
|
266
|
+
const buffer = Buffer.alloc(REGISTRATION_MAX + 1)
|
|
267
|
+
const length = readSync(fd, buffer, 0, buffer.length, 0)
|
|
268
|
+
if (length > REGISTRATION_MAX) return undefined
|
|
269
|
+
info = JSON.parse(buffer.subarray(0, length).toString("utf8"))
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined
|
|
272
|
+
} finally {
|
|
273
|
+
if (fd !== undefined) closeSync(fd)
|
|
274
|
+
}
|
|
275
|
+
if (!info || typeof info !== "object") return undefined
|
|
276
|
+
const { url, pid, password } = info as Record<string, unknown>
|
|
277
|
+
if (typeof url !== "string" || url.length > 2048 || typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined
|
|
278
|
+
try {
|
|
279
|
+
process.kill(pid, 0)
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined // gone (ESRCH), or another user's process (EPERM): not this user's service
|
|
282
|
+
}
|
|
283
|
+
return typeof password === "string" ? { url, auth: { type: "basic", username: "opencode", password } } : { url }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Pseudo-file systems: their files report size 0 yet read data (/proc/self/environ), or are devices. */
|
|
287
|
+
const PSEUDO_ROOTS = ["/proc", "/sys", "/dev"]
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A server password from a file (its first line): only a private regular file (not a link, FIFO, device or socket,
|
|
291
|
+
* nothing under /proc, /sys or /dev, no symbolic link in its directory path, no group or other access, owned by this
|
|
292
|
+
* user) of 1 byte to 4 KiB. Never blocks: the file is checked with lstat before it is opened, and opened with
|
|
293
|
+
* O_NONBLOCK and O_NOFOLLOW (then checked again on the descriptor). Problems never include the content.
|
|
294
|
+
*/
|
|
295
|
+
export function readPasswordFile(file: string): { password: string } | { problem: string } {
|
|
296
|
+
if (!path.isAbsolute(file)) return { problem: `password file ${file} is not an absolute path` }
|
|
297
|
+
const resolved = path.resolve(file)
|
|
298
|
+
if (PSEUDO_ROOTS.some((root) => resolved === root || resolved.startsWith(`${root}/`))) return { problem: `password file ${file} is not a regular file` }
|
|
299
|
+
try {
|
|
300
|
+
const dir = path.dirname(resolved)
|
|
301
|
+
if (realpathSync(dir) !== dir) return { problem: `password file ${file} is reached through a symbolic link (use its real path)` }
|
|
302
|
+
} catch {
|
|
303
|
+
return { problem: `password file ${file} does not exist` }
|
|
304
|
+
}
|
|
305
|
+
let before: Stats
|
|
306
|
+
try {
|
|
307
|
+
before = lstatSync(resolved)
|
|
308
|
+
} catch (cause) {
|
|
309
|
+
return { problem: `password file ${file} ${(cause as NodeJS.ErrnoException).code === "ENOENT" ? "does not exist" : "cannot be read"}` }
|
|
310
|
+
}
|
|
311
|
+
if (before.isSymbolicLink()) return { problem: `password file ${file} is a symbolic link` }
|
|
312
|
+
if (!before.isFile()) return { problem: `password file ${file} is not a regular file` }
|
|
313
|
+
let fd: number
|
|
314
|
+
try {
|
|
315
|
+
fd = openSync(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
316
|
+
} catch (cause) {
|
|
317
|
+
const code = (cause as NodeJS.ErrnoException).code
|
|
318
|
+
return { problem: `password file ${file} ${code === "ELOOP" ? "is a symbolic link" : code === "ENOENT" ? "does not exist" : "cannot be read"}` }
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const stat = fstatSync(fd)
|
|
322
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return { problem: `password file ${file} is not a regular file` }
|
|
323
|
+
if ((stat.mode & 0o077) !== 0) return { problem: `password file ${file} is readable by others (chmod 600 it)` }
|
|
324
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return { problem: `password file ${file} is not owned by this user` }
|
|
325
|
+
if (stat.size === 0) return { problem: `password file ${file} is empty` }
|
|
326
|
+
if (stat.size > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
|
|
327
|
+
const buffer = Buffer.alloc(PASSWORD_FILE_MAX + 1)
|
|
328
|
+
const length = readSync(fd, buffer, 0, buffer.length, 0)
|
|
329
|
+
if (length > stat.size || length > PASSWORD_FILE_MAX) return { problem: `password file ${file} is too large` }
|
|
330
|
+
const password = buffer.subarray(0, length).toString("utf8").split(/\r?\n/)[0]!.trim()
|
|
331
|
+
if (!password) return { problem: `password file ${file} is empty` }
|
|
332
|
+
return { password }
|
|
333
|
+
} finally {
|
|
334
|
+
closeSync(fd)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A node.json server's endpoint: its URL with the password read from its file on every discovery (rotation). */
|
|
339
|
+
export function serverEndpoint(server: { readonly url: string; readonly passwordFile: string }): () => Promise<WarmEndpoint> {
|
|
340
|
+
return async () => {
|
|
341
|
+
const read = readPasswordFile(server.passwordFile)
|
|
342
|
+
return "password" in read ? { url: server.url, auth: { type: "basic", username: "opencode", password: read.password } } : { url: server.url, problem: read.problem }
|
|
343
|
+
}
|
|
344
|
+
}
|
package/packages/node/src/run.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// also offers the openshell runtime (sandboxes through the openshell CLI): it checks the CLI, asks the gateway for
|
|
4
4
|
// its backends and sweeps this machine's sandboxes from an earlier run into the hello; an unusable CLI is logged and
|
|
5
5
|
// the node runs with the host runtime only. The host runtime offers the harnesses found on this machine
|
|
6
|
-
// (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding).
|
|
6
|
+
// (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding). While it offers opencode, the
|
|
7
|
+
// node keeps OpenCode's background service and node.json's foreground servers warm (opencode-warm.ts) so their hub
|
|
8
|
+
// plugin loads without a client opening a project. Exit codes: 0 after a clean stop, 1 when
|
|
7
9
|
// the machine is not enrolled, EXIT_CONFIG (78, EX_CONFIG) when node.json gives the node nothing to do; the
|
|
8
10
|
// service units do not restart on 78.
|
|
9
11
|
import { homedir } from "node:os"
|
|
12
|
+
import path from "node:path"
|
|
10
13
|
import type { NodeLaunchReport } from "../../protocol/src/index.ts"
|
|
11
14
|
import { OpenShellCli } from "../../runtime-openshell/src/cli.ts"
|
|
12
15
|
import type { Runtime } from "../../runtime-sdk/src/index.ts"
|
|
@@ -14,6 +17,7 @@ import { nodeConfigPath, readNodeConfig, readSpokeCredentials, type NodeConfig,
|
|
|
14
17
|
import { NodeDaemon, NODE_VERSION } from "./daemon.ts"
|
|
15
18
|
import { detectHarnesses } from "./harnesses.ts"
|
|
16
19
|
import { HostRuntime } from "./host.ts"
|
|
20
|
+
import { OpenCodeWarmer, readServiceRegistration, serverEndpoint, type WarmEndpoint } from "./opencode-warm.ts"
|
|
17
21
|
import { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
|
|
18
22
|
|
|
19
23
|
export const EXIT_CONFIG = 78
|
|
@@ -29,6 +33,28 @@ export interface RunNodeOptions {
|
|
|
29
33
|
readonly signal: AbortSignal
|
|
30
34
|
/** The openshell executable (default "openshell" on PATH). */
|
|
31
35
|
readonly openshellBinary?: string
|
|
36
|
+
/** Finds OpenCode's background service (default: readServiceRegistration on this env's registration file). */
|
|
37
|
+
readonly openCodeService?: () => Promise<WarmEndpoint | undefined>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** OpenCode's service registration file for `env` and `home` (@opencode/client's default location). */
|
|
41
|
+
export const openCodeServiceFile = (env: Env, home: string): string =>
|
|
42
|
+
path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "opencode", "service.json")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Keeps OpenCode's hub plugin loaded (opencode-warm.ts): the background service and each node.json
|
|
47
|
+
* `opencode.servers` entry (a foreground `opencode serve`), at their default location and project directories
|
|
48
|
+
* (listed, else the projects of their recent sessions under the workspace roots).
|
|
49
|
+
*/
|
|
50
|
+
function startOpenCodeWarmers(config: NodeConfig, service: () => Promise<WarmEndpoint | undefined>, log: (line: string) => void, signal: AbortSignal): void {
|
|
51
|
+
const roots = () => config.workspaceRoots
|
|
52
|
+
const directories = config.opencode?.directories
|
|
53
|
+
const timeout = config.opencode?.timeoutSeconds !== undefined ? { timeoutMs: config.opencode.timeoutSeconds * 1000 } : {}
|
|
54
|
+
new OpenCodeWarmer({ discover: service, log, roots, ...timeout, ...(directories ? { directories } : {}) }).start(signal)
|
|
55
|
+
for (const server of config.opencode?.servers ?? []) {
|
|
56
|
+
new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...timeout, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
|
|
57
|
+
}
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
interface OpenShellSetup {
|
|
@@ -96,6 +122,7 @@ export async function runNode(options: RunNodeOptions): Promise<number> {
|
|
|
96
122
|
})
|
|
97
123
|
log(`hub node ${NODE_VERSION}: ${config.slots} slots, roots ${config.workspaceRoots.join(", ")}; host harnesses ${harnesses.join(", ") || "none"}`)
|
|
98
124
|
daemon.start()
|
|
125
|
+
if (harnesses.includes("opencode")) startOpenCodeWarmers(config, options.openCodeService ?? (() => readServiceRegistration(openCodeServiceFile(env, home))), log, options.signal)
|
|
99
126
|
await new Promise<void>((resolve) => {
|
|
100
127
|
if (options.signal.aborted) return resolve()
|
|
101
128
|
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Profile application on preset sessions (HWA-18; protocol packages/protocol/src/profiles.ts). A preset session is a
|
|
2
|
+
// terminal the spoke pastes into (evidence "screen"): there is no session-scoped place for instructions, skills or
|
|
3
|
+
// commands, the model is fixed by the harness, and the spoke could not confirm any change took effect. Typing a
|
|
4
|
+
// profile into the conversation would be a prompt, not a profile, so nothing is ever pasted.
|
|
5
|
+
//
|
|
6
|
+
// The spoke still announces profileApply so the answer is explicit: every known session's request is answered
|
|
7
|
+
// "unsupported" with one note per part and PRESET_PROFILE_NOTE; a mismatched digest or an unknown session is
|
|
8
|
+
// "rejected". Command-only support (rendering a command and pasting it at invocation) is not offered: on screen
|
|
9
|
+
// evidence it cannot be verified.
|
|
10
|
+
import type { ProfileApplyRequest, ProfileApplyResult } from "../../protocol/src/index.ts"
|
|
11
|
+
import { planProfileApplication, profileApplyResult, profileDigest, type ProfileCapabilities } from "../../spoke-sdk/src/index.ts"
|
|
12
|
+
|
|
13
|
+
/** What the preset spoke can apply of a profile to a running harness: nothing. */
|
|
14
|
+
export const PRESET_PROFILE_CAPABILITIES: ProfileCapabilities = Object.freeze({
|
|
15
|
+
instructions: false,
|
|
16
|
+
skills: false,
|
|
17
|
+
commands: false,
|
|
18
|
+
model: false,
|
|
19
|
+
live: false,
|
|
20
|
+
renderAtInvocation: false,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const PRESET_PROFILE_NOTE =
|
|
24
|
+
"preset sessions are terminals the spoke pastes into: a running harness's instructions, skills, commands or model cannot be changed without typing into the conversation, and no change could be confirmed (screen evidence)"
|
|
25
|
+
|
|
26
|
+
/** The `profileApply` op handler for a preset spoke whose sessions are `has`. */
|
|
27
|
+
export function presetProfileApply(has: (sessionID: string) => boolean): (req: ProfileApplyRequest) => Promise<ProfileApplyResult> {
|
|
28
|
+
return async (req) => {
|
|
29
|
+
if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
|
|
30
|
+
if (!has(req.sessionID)) return profileApplyResult(req, "rejected", [`unknown session ${req.sessionID}`])
|
|
31
|
+
const plan = planProfileApplication(req, { capabilities: PRESET_PROFILE_CAPABILITIES })
|
|
32
|
+
return profileApplyResult(req, "unsupported", [...plan.notes, PRESET_PROFILE_NOTE])
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// is "retained" (nothing is typed into the user's input line) and pasted ahead of the session's next turn or
|
|
9
9
|
// message; a prompt (hubPrompt) goes in its provenance envelope;
|
|
10
10
|
// - interrupt and stop: Ctrl-C, only when the backend can send keys;
|
|
11
|
+
// - profileApply: answered "unsupported" with notes (or "rejected"), never pasted (profile.ts);
|
|
11
12
|
// - never permission, form or steer (a terminal paste can answer neither a dialog nor a running turn reliably);
|
|
12
13
|
// - never an event: no activity, and above all no idle, is inferred from the screen (the hub never treats screen
|
|
13
14
|
// evidence as idle anyway). The screen is only sampled for the harness's native session ID (source screen) and the
|
|
@@ -21,6 +22,7 @@ import path from "node:path"
|
|
|
21
22
|
import type { HarnessPreset, SessionInfo } from "../../protocol/src/index.ts"
|
|
22
23
|
import { deliverText, HarnessSpoke, hubSessionID, type Admission, type DeliverRequest, type HarnessSpokeOptions, type OpHandlers } from "../../spoke-sdk/src/index.ts"
|
|
23
24
|
import { launchArgv, nativeIDFromScreen } from "./presets.ts"
|
|
25
|
+
import { presetProfileApply } from "./profile.ts"
|
|
24
26
|
import type { TerminalBackend, TerminalPane } from "./tmux.ts"
|
|
25
27
|
|
|
26
28
|
/** The hub session ID namespace of preset sessions (ses_pr…). */
|
|
@@ -111,6 +113,8 @@ export class PresetSpoke {
|
|
|
111
113
|
await this.#submit(this.#session(req.sessionID), req.text)
|
|
112
114
|
return { outcome: "timeout", reply: "" }
|
|
113
115
|
},
|
|
116
|
+
// Answered explicitly, never applied (profile.ts): unsupported with notes, or rejected.
|
|
117
|
+
profileApply: presetProfileApply((id) => this.#sessions.has(id)),
|
|
114
118
|
...(this.#backend.canSendKeys
|
|
115
119
|
? {
|
|
116
120
|
interrupt: async (req: { sessionID: string }) => (await this.#interrupt(req.sessionID), {}),
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Persistent artifacts (HWA-02 contract; metadata/revisions + BlobStore interface HWA-20, standalone blobs and
|
|
2
|
+
// transfer API HWA-21, cluster blobs HWA-22, viewers HWA-23, AI jobs HWA-24/25, isolated app host HWA-26/27).
|
|
3
|
+
//
|
|
4
|
+
// Invariants frozen here:
|
|
5
|
+
// - Blobs are content-addressed (sha256) and immutable; revisions reference blobs; metadata lives in the state
|
|
6
|
+
// store. Standalone keeps blobs as files under the hub data directory; a cluster keeps them in a chunked
|
|
7
|
+
// Postgres table (decision tctkrik8). Nothing lives in ephemeral session or launch paths.
|
|
8
|
+
// - Access: private to the owner by default. Visibility widens only explicitly; every read checks it. AI actions
|
|
9
|
+
// run with the viewer's own authority (no privilege laundering through the artifact or its job session).
|
|
10
|
+
// - Interactive artifacts ("app" kind) run only in the isolated artifact host (separate origin, sandboxed frame,
|
|
11
|
+
// CSP, capability bridge); never inside the console bundle, never as a console plugin, never in central.
|
|
12
|
+
// - Concurrent revisions: a new revision names `baseRevisionID`; a stale base is 409 `conflict` (no silent merge).
|
|
13
|
+
import { DESCRIPTION_MAX, isContractID, isLabel, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
|
|
14
|
+
import { isObj } from "./wire.ts"
|
|
15
|
+
|
|
16
|
+
export const ARTIFACT_LIMITS = {
|
|
17
|
+
artifactsPerUser: 5_000,
|
|
18
|
+
revisionsPerArtifact: 500,
|
|
19
|
+
/** Bytes of one blob and of all of one user's blobs. */
|
|
20
|
+
blobBytes: 25 * 1024 * 1024,
|
|
21
|
+
userBlobBytes: 2 * 1024 * 1024 * 1024,
|
|
22
|
+
/** Files in a multi-file ("bundle") revision, e.g. an app. */
|
|
23
|
+
bundleFiles: 200,
|
|
24
|
+
/** Chunk size of the cluster Postgres blob table. */
|
|
25
|
+
chunkBytes: 1024 * 1024,
|
|
26
|
+
/** Concurrent AI jobs per user and per artifact. */
|
|
27
|
+
aiJobsPerUser: 8,
|
|
28
|
+
aiJobsPerArtifact: 2,
|
|
29
|
+
/** Characters of an AI job instruction. */
|
|
30
|
+
aiInstructionChars: 8_000,
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export const ARTIFACT_SCOPES = ["artifacts:read", "artifacts:write", "artifacts:ai"] as const
|
|
34
|
+
|
|
35
|
+
/** What renders an artifact. Declarative kinds render in the console; "app" only in the isolated host. */
|
|
36
|
+
export const ARTIFACT_KINDS = ["markdown", "code", "image", "table", "chart", "dashboard", "html", "app", "file"] as const
|
|
37
|
+
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
|
|
38
|
+
export const ISOLATED_KINDS: readonly ArtifactKind[] = ["html", "app"]
|
|
39
|
+
|
|
40
|
+
export type ArtifactVisibility = "private" | "workspace"
|
|
41
|
+
|
|
42
|
+
export interface Artifact extends Revisioned {
|
|
43
|
+
readonly v: 1
|
|
44
|
+
readonly id: string
|
|
45
|
+
readonly title: string
|
|
46
|
+
readonly description?: string
|
|
47
|
+
readonly kind: ArtifactKind
|
|
48
|
+
readonly state: RecordState
|
|
49
|
+
/** "private": owner only; "workspace": the owner plus sessions that are current members of workspaceID. */
|
|
50
|
+
readonly visibility: ArtifactVisibility
|
|
51
|
+
readonly workspaceID?: string
|
|
52
|
+
/** Session that created it (provenance only; grants nothing). */
|
|
53
|
+
readonly createdBySessionID?: string
|
|
54
|
+
readonly latestRevisionID: string
|
|
55
|
+
readonly tags?: readonly string[]
|
|
56
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
57
|
+
readonly createdAt: number
|
|
58
|
+
readonly updatedAt: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BlobRef {
|
|
62
|
+
/** `sha256:<64 hex>`. */
|
|
63
|
+
readonly hash: string
|
|
64
|
+
readonly bytes: number
|
|
65
|
+
/** A media type from a fixed allowlist (the transfer API rejects others). */
|
|
66
|
+
readonly mediaType: string
|
|
67
|
+
/** Path inside a bundle revision; absent for a single-file revision. */
|
|
68
|
+
readonly path?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ArtifactRevision {
|
|
72
|
+
readonly v: 1
|
|
73
|
+
readonly id: string
|
|
74
|
+
readonly artifactID: string
|
|
75
|
+
readonly number: number
|
|
76
|
+
readonly baseRevisionID?: string
|
|
77
|
+
readonly blobs: readonly BlobRef[]
|
|
78
|
+
/** Entry file of a bundle (apps, html). */
|
|
79
|
+
readonly entry?: string
|
|
80
|
+
readonly author: { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string } | { readonly kind: "ai_job"; readonly jobID: string }
|
|
81
|
+
readonly createdAt: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type AiJobState = "queued" | "running" | "succeeded" | "failed" | "cancelled"
|
|
85
|
+
|
|
86
|
+
/** An agent-backed AI action on an artifact, durable across hub restarts; runs as the requesting viewer. */
|
|
87
|
+
export interface ArtifactAiJob extends Revisioned {
|
|
88
|
+
readonly v: 1
|
|
89
|
+
readonly id: string
|
|
90
|
+
readonly artifactID: string
|
|
91
|
+
readonly baseRevisionID: string
|
|
92
|
+
readonly requestedBy: { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string }
|
|
93
|
+
readonly state: AiJobState
|
|
94
|
+
/** Launch or session doing the work (when started). */
|
|
95
|
+
readonly launchID?: string
|
|
96
|
+
readonly sessionID?: string
|
|
97
|
+
readonly resultRevisionID?: string
|
|
98
|
+
readonly error?: string
|
|
99
|
+
readonly createdAt: number
|
|
100
|
+
readonly updatedAt: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const ARTIFACT_EVENT_TYPES = ["artifact.created", "artifact.revised", "artifact.archived", "artifact.visibility", "artifact.job"] as const
|
|
104
|
+
|
|
105
|
+
/** Media types the transfer API accepts. Anything else is rejected, never sniffed. */
|
|
106
|
+
export const ARTIFACT_MEDIA_TYPES = [
|
|
107
|
+
"text/markdown",
|
|
108
|
+
"text/plain",
|
|
109
|
+
"text/csv",
|
|
110
|
+
"text/html",
|
|
111
|
+
"text/css",
|
|
112
|
+
"text/javascript",
|
|
113
|
+
"application/json",
|
|
114
|
+
"application/pdf",
|
|
115
|
+
"image/png",
|
|
116
|
+
"image/jpeg",
|
|
117
|
+
"image/gif",
|
|
118
|
+
"image/webp",
|
|
119
|
+
"image/svg+xml",
|
|
120
|
+
] as const
|
|
121
|
+
|
|
122
|
+
export const isBlobHash = (v: unknown): v is string => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v)
|
|
123
|
+
|
|
124
|
+
export function artifactInputProblem(v: unknown): string | undefined {
|
|
125
|
+
if (!isObj(v)) return "body must be an object"
|
|
126
|
+
if (!isLabel(v.title)) return "title must be 1–120 characters of text"
|
|
127
|
+
if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
|
|
128
|
+
if (!ARTIFACT_KINDS.includes(v.kind as ArtifactKind)) return `kind must be one of ${ARTIFACT_KINDS.join(", ")}`
|
|
129
|
+
if (v.visibility !== undefined && v.visibility !== "private" && v.visibility !== "workspace") return "visibility must be private or workspace"
|
|
130
|
+
if (v.visibility === "workspace" && !isContractID("workspace", v.workspaceID)) return "workspace visibility needs a workspaceID"
|
|
131
|
+
if (v.workspaceID !== undefined && !isContractID("workspace", v.workspaceID)) return "workspaceID must be a workspace ID"
|
|
132
|
+
return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function blobRefProblem(v: unknown): string | undefined {
|
|
136
|
+
if (!isObj(v)) return "blob must be an object"
|
|
137
|
+
if (!isBlobHash(v.hash)) return "hash must be sha256:<64 hex>"
|
|
138
|
+
if (typeof v.bytes !== "number" || !Number.isSafeInteger(v.bytes) || v.bytes < 0 || v.bytes > ARTIFACT_LIMITS.blobBytes) return `bytes must be 0–${ARTIFACT_LIMITS.blobBytes}`
|
|
139
|
+
if (!ARTIFACT_MEDIA_TYPES.includes(v.mediaType as (typeof ARTIFACT_MEDIA_TYPES)[number])) return "mediaType is not allowed"
|
|
140
|
+
if (v.path !== undefined && (typeof v.path !== "string" || !/^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[A-Za-z0-9._/-]{1,200}$/.test(v.path))) return "path must be a relative path without . or .. segments"
|
|
141
|
+
return undefined
|
|
142
|
+
}
|