rvlib-shipkit 0.6.0 → 0.7.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 +34 -1
- package/package.json +3 -2
- package/src/assets/gen/derived.ts +83 -0
- package/src/assets/gen/gemini-gen.ts +77 -0
- package/src/assets/gen/generate-roll.ts +12 -1
- package/src/assets/kit-config.ts +25 -2
- package/src/assets/run-assets.ts +68 -27
- package/src/assets/server/studio-page.ts +3 -2
- package/src/assets/server/studio-server.ts +31 -6
- package/src/serve/hub-app/hub-app.tsx +156 -0
- package/src/serve/hub-app/hub.css +41 -0
- package/src/serve/hub-app/index.html +13 -0
- package/src/serve/hub-pages.ts +39 -78
- package/src/serve/run-serve.ts +83 -4
- package/src/serve/serve-hub.ts +30 -9
- package/src/shipkit.ts +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
New (the hub becomes THE dashboard; autonomy over nagging):
|
|
6
|
+
|
|
7
|
+
- hub UI rebuilt as a **Bun-native React app** (Bun.serve + HTML import, no
|
|
8
|
+
vite, no build step; MobX store). The old template-string landing +
|
|
9
|
+
status pages are DELETED.
|
|
10
|
+
- `/` IS the **attention dashboard**: failing checks (STATUS.md), open
|
|
11
|
+
asks, missing/unpicked/stale assets, contract gaps, planned posts — every
|
|
12
|
+
line links to its owning surface. Data via `/api/attention.json` +
|
|
13
|
+
`/api/status.json`; sources are failure-isolated (a broken collector
|
|
14
|
+
reports itself, never blanks the page).
|
|
15
|
+
- a thin left **rail** (⚡🕸🤖🖼📊, active-tab marked, red dot when
|
|
16
|
+
anything red) on EVERY hub page — React pages natively, legacy pages
|
|
17
|
+
(graph pages, studio SPA) via server-side injection until they migrate.
|
|
18
|
+
- **derived assets**: `from: ['parentId']` on an AssetSpec + the
|
|
19
|
+
`gemini({ model? })` backend (plain fetch, key from env GEMINI_API_KEY —
|
|
20
|
+
unset dies loud). The asset's prompt is the EDIT instruction; parents'
|
|
21
|
+
PICKED rolls feed generation as context images. Unknown refs, cycles and
|
|
22
|
+
non-gemini `from` die at defineKit. Each roll records its parent picks in
|
|
23
|
+
its meta sidecar.
|
|
24
|
+
- **autonomy contract — machine-clearable red clears itself**: re-picking a
|
|
25
|
+
parent AUTO-enqueues every now-stale child (no button, no nag; spend
|
|
26
|
+
gated on the intentful pick). Identity self-heals the same way: brand.ts
|
|
27
|
+
content hash in `.rv-identity/brand.hash`; `assets check` and the
|
|
28
|
+
attention collector detect drift and RE-RENDER public/* themselves —
|
|
29
|
+
only a missing pick still reaches a human. The old
|
|
30
|
+
"WARN outputs missing — run identity" line is gone.
|
|
31
|
+
- fill/add walk parents-first (topo) so derived children never generate
|
|
32
|
+
before their parents; a blocked derived asset FAILs naming the parent.
|
|
33
|
+
- studio: stale = orange sidebar dot; `/api/state` gains
|
|
34
|
+
`derived`/`stale` per asset.
|
|
35
|
+
|
|
3
36
|
## 0.6.0 — 2026-07-22
|
|
4
37
|
|
|
5
38
|
New (wave 3 of the shipkit expansion — media + manual assets):
|
|
@@ -70,7 +103,7 @@ New (wave 1 of the shipkit expansion — uniform repo setup):
|
|
|
70
103
|
|
|
71
104
|
- fix: `react-dom` was missing from dependencies AND the CLI entry loaded
|
|
72
105
|
the react/ink chain eagerly — every command crashed in a fresh consumer
|
|
73
|
-
repo without react-dom (found by
|
|
106
|
+
repo without react-dom (found by a consumer-repo swap in the field,
|
|
74
107
|
confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
|
|
75
108
|
carry react-dom. Proven by a fresh-repo install test (init → ci green in
|
|
76
109
|
a react-less repo).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "^1.3.14",
|
|
23
|
-
"@types/react": "^19.2.17"
|
|
23
|
+
"@types/react": "^19.2.17",
|
|
24
|
+
"@types/react-dom": "^19.2.3"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@napi-rs/canvas": "^1.0.2",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Derived assets — `from: ['parentId']` on an AssetSpec: the parents'
|
|
2
|
+
// PICKED rolls feed generation as context images; each generated roll
|
|
3
|
+
// records the parent picks in its meta sidecar; a parent re-pick flips
|
|
4
|
+
// children to STALE. Shared by the studio queue and the fill/gen CLI.
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { findAsset, type FoundAsset, type KitConfig } from '../kit-config.ts'
|
|
7
|
+
import { assetDir, listRolls, metaPath, readPick } from '../roll-workspace.ts'
|
|
8
|
+
import { readJson, updateJson } from '../json-store.ts'
|
|
9
|
+
import type { ContextImage } from './gemini-gen.ts'
|
|
10
|
+
|
|
11
|
+
const MIME: Record<string, string> = { png: 'image/png', webp: 'image/webp' }
|
|
12
|
+
|
|
13
|
+
export type ParentContext = { images: ContextImage[]; parents: Record<string, string> }
|
|
14
|
+
|
|
15
|
+
export const parentIdsOf = (f: FoundAsset): string[] => f.asset.from ?? []
|
|
16
|
+
|
|
17
|
+
/** Parents' picked rolls as base64 context — or the list of blockers
|
|
18
|
+
* (unpicked/unknown parents), never a silent partial. */
|
|
19
|
+
export async function resolveParentContext(kit: KitConfig, workspace: string, f: FoundAsset): Promise<ParentContext | { missing: string[] }> {
|
|
20
|
+
const missing: string[] = []
|
|
21
|
+
const images: ContextImage[] = []
|
|
22
|
+
const parents: Record<string, string> = {}
|
|
23
|
+
for (const id of parentIdsOf(f)) {
|
|
24
|
+
const parent = findAsset(kit, id)
|
|
25
|
+
if (parent == null) { missing.push(`${id} (not in kit)`); continue }
|
|
26
|
+
const dir = assetDir(workspace, id)
|
|
27
|
+
const pick = (await readPick(dir)).pick
|
|
28
|
+
if (pick == null) { missing.push(`${id} (no pick)`); continue }
|
|
29
|
+
const ext = pick.split('.').at(-1)!
|
|
30
|
+
const file = Bun.file(join(dir, pick))
|
|
31
|
+
if (!(await file.exists())) { missing.push(`${id} (picked roll ${pick} vanished)`); continue }
|
|
32
|
+
images.push({ mime: MIME[ext] ?? 'image/png', base64: Buffer.from(await file.arrayBuffer()).toString('base64') })
|
|
33
|
+
parents[id] = pick
|
|
34
|
+
}
|
|
35
|
+
if (missing.length > 0) return { missing }
|
|
36
|
+
return { images, parents }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stamp the parent picks onto every roll of this seed (v<seed>.*) — called
|
|
40
|
+
* after a successful derived generation. */
|
|
41
|
+
export async function recordParents(destDir: string, seed: number, parents: Record<string, string>): Promise<void> {
|
|
42
|
+
if (Object.keys(parents).length === 0) return
|
|
43
|
+
for (const roll of listRolls(destDir).filter((r) => r.startsWith(`v${seed}.`) || r.startsWith(`v${seed}-`)))
|
|
44
|
+
await updateJson<{ parents?: Record<string, string> }>(metaPath(destDir, roll), {}, (m) => { m.parents = parents })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A derived asset is STALE when its PICKED roll's recorded parent picks
|
|
48
|
+
* differ from the parents' CURRENT picks (or were never recorded). An
|
|
49
|
+
* unpicked asset is never stale — it's just unpicked. */
|
|
50
|
+
export async function isStale(kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
|
|
51
|
+
const parentIds = parentIdsOf(f)
|
|
52
|
+
if (parentIds.length === 0) return false
|
|
53
|
+
const dir = assetDir(workspace, f.asset.id)
|
|
54
|
+
const pick = (await readPick(dir)).pick
|
|
55
|
+
if (pick == null) return false
|
|
56
|
+
const meta = await readJson<{ parents?: Record<string, string> }>(metaPath(dir, pick), {})
|
|
57
|
+
for (const id of parentIds) {
|
|
58
|
+
const current = (await readPick(assetDir(workspace, id))).pick
|
|
59
|
+
if (current == null) continue // parent lost its pick — child can't regen anyway; blocker surfaces at generate
|
|
60
|
+
if (meta.parents?.[id] !== current) return true
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parents-first order for batch generation (fill/add, regen-stale).
|
|
66
|
+
* Cycles are rejected at defineKit; a stray one here throws loud. */
|
|
67
|
+
export function topoSort(assets: FoundAsset[]): FoundAsset[] {
|
|
68
|
+
const byId = new Map(assets.map((f) => [f.asset.id, f]))
|
|
69
|
+
const done = new Set<string>()
|
|
70
|
+
const visiting = new Set<string>()
|
|
71
|
+
const out: FoundAsset[] = []
|
|
72
|
+
const visit = (f: FoundAsset): void => {
|
|
73
|
+
if (done.has(f.asset.id)) return
|
|
74
|
+
if (visiting.has(f.asset.id)) throw new Error(`derived-asset cycle through '${f.asset.id}'`)
|
|
75
|
+
visiting.add(f.asset.id)
|
|
76
|
+
for (const id of parentIdsOf(f)) { const p = byId.get(id); if (p != null) visit(p) }
|
|
77
|
+
visiting.delete(f.asset.id)
|
|
78
|
+
done.add(f.asset.id)
|
|
79
|
+
out.push(f)
|
|
80
|
+
}
|
|
81
|
+
for (const f of assets) visit(f)
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Gemini image backend — plain fetch, no SDK. THE backend for
|
|
2
|
+
// edit-from-parent flows (context images = parents' picked rolls); comfy
|
|
3
|
+
// stays for from-scratch art. Key from env (default GEMINI_API_KEY) — a
|
|
4
|
+
// missing key dies loud at generation, never silently.
|
|
5
|
+
import { mkdirSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
|
|
8
|
+
export type GeminiModel = 'flash' | 'pro'
|
|
9
|
+
export const GEMINI_MODELS: Record<GeminiModel, string> = {
|
|
10
|
+
flash: 'gemini-2.5-flash-image',
|
|
11
|
+
pro: 'gemini-3-pro-image-preview',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type GeminiOpts = {
|
|
15
|
+
/** env var carrying the API key (default GEMINI_API_KEY) */
|
|
16
|
+
apiKeyEnv?: string
|
|
17
|
+
/** test seam / self-host — default the real Google endpoint */
|
|
18
|
+
host?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ContextImage = { mime: string; base64: string }
|
|
22
|
+
|
|
23
|
+
const HOST = 'https://generativelanguage.googleapis.com'
|
|
24
|
+
|
|
25
|
+
export function geminiKey(o: GeminiOpts): string {
|
|
26
|
+
const env = o.apiKeyEnv ?? 'GEMINI_API_KEY'
|
|
27
|
+
const key = process.env[env]
|
|
28
|
+
if (key == null || key === '')
|
|
29
|
+
throw new Error(`env ${env} unset — the gemini() backend needs it (mint: aistudio.google.com/apikey, export it in the repo's scripts)`)
|
|
30
|
+
return key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type GenerateContentResponse = {
|
|
34
|
+
candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[]
|
|
35
|
+
error?: { message?: string }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One generation: prompt + context images → image roll(s) saved as
|
|
39
|
+
* <destDir>/<name>.png (suffix -2… on multi-image). Returns saved paths. */
|
|
40
|
+
export async function generateGemini(
|
|
41
|
+
o: GeminiOpts,
|
|
42
|
+
args: { prompt: string; model?: GeminiModel; destDir: string; name: string; context: ContextImage[] },
|
|
43
|
+
): Promise<string[]> {
|
|
44
|
+
const key = geminiKey(o)
|
|
45
|
+
const model = GEMINI_MODELS[args.model ?? 'flash']
|
|
46
|
+
const res = await fetch(`${o.host ?? HOST}/v1beta/models/${model}:generateContent`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
contents: [{ parts: [{ text: args.prompt }, ...args.context.map((c) => ({ inlineData: { mimeType: c.mime, data: c.base64 } }))] }],
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
if (!res.ok) throw new Error(`gemini ${model} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
54
|
+
const body = (await res.json()) as GenerateContentResponse
|
|
55
|
+
const parts = body.candidates?.[0]?.content?.parts ?? []
|
|
56
|
+
mkdirSync(args.destDir, { recursive: true })
|
|
57
|
+
const saved: string[] = []
|
|
58
|
+
for (const p of parts) {
|
|
59
|
+
if (p.inlineData?.data == null) continue
|
|
60
|
+
// roll names must satisfy the workspace regex (v<seed>.png|webp) or
|
|
61
|
+
// the studio never lists them — an invisible paid roll is the worst
|
|
62
|
+
// failure mode. Unexpected mimes die loud; extra images get their
|
|
63
|
+
// own seed-valid names instead of an unlistable -2 suffix.
|
|
64
|
+
const mime = p.inlineData.mimeType ?? 'image/png'
|
|
65
|
+
const ext = mime === 'image/webp' ? 'webp' : mime === 'image/png' ? 'png' : null
|
|
66
|
+
if (ext == null) throw new Error(`gemini returned mime '${mime}' — only png/webp land as rolls (got ${parts.length} part(s))`)
|
|
67
|
+
const name = saved.length === 0 ? args.name : `v${Math.floor(Math.random() * 2_000_000_000) + 1}`
|
|
68
|
+
const dest = join(args.destDir, `${name}.${ext}`)
|
|
69
|
+
await Bun.write(dest, Buffer.from(p.inlineData.data, 'base64'))
|
|
70
|
+
saved.push(dest)
|
|
71
|
+
}
|
|
72
|
+
if (saved.length === 0) {
|
|
73
|
+
const text = parts.find((p) => p.text != null)?.text?.slice(0, 160)
|
|
74
|
+
throw new Error(`gemini ${model} returned no image${text != null ? ` — model said: ${text}` : ''} (blocked prompt? wrong model?)`)
|
|
75
|
+
}
|
|
76
|
+
return saved
|
|
77
|
+
}
|
|
@@ -8,6 +8,8 @@ import { mkdirSync } from 'fs'
|
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import type { FoundAsset } from '../kit-config.ts'
|
|
10
10
|
import { generate, type OnProgress } from './comfy-gen.ts'
|
|
11
|
+
import { recordParents, type ParentContext } from './derived.ts'
|
|
12
|
+
import { generateGemini } from './gemini-gen.ts'
|
|
11
13
|
import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
|
|
12
14
|
|
|
13
15
|
/** roll canvas master size (square) — favicon/logo/sprites derive from it */
|
|
@@ -20,12 +22,21 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
|
|
|
20
22
|
* v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
|
|
21
23
|
export async function generateRoll(
|
|
22
24
|
found: FoundAsset,
|
|
23
|
-
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
|
|
25
|
+
args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
|
|
24
26
|
resolveComfy: ComfyResolver,
|
|
25
27
|
): Promise<void> {
|
|
26
28
|
const gen = found.kind.generate
|
|
27
29
|
if (gen.backend === 'manual')
|
|
28
30
|
throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
|
|
31
|
+
if (gen.backend === 'gemini') {
|
|
32
|
+
// derived assets NEED their parents' picked rolls — the caller
|
|
33
|
+
// resolves them (queue/CLI) so a blocker names the parent, loud
|
|
34
|
+
if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
|
|
35
|
+
throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
|
|
36
|
+
await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
|
|
37
|
+
if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
29
40
|
if (gen.backend === 'canvas') {
|
|
30
41
|
const { createCanvas } = await import('@napi-rs/canvas')
|
|
31
42
|
const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
|
package/src/assets/kit-config.ts
CHANGED
|
@@ -10,9 +10,13 @@ import type { GroundRect } from './panels/geometry-rects.ts'
|
|
|
10
10
|
|
|
11
11
|
export type AssetSpec = {
|
|
12
12
|
id: string
|
|
13
|
-
/** FULL prompt text incl. style — the studio shows/edits exactly what
|
|
13
|
+
/** FULL prompt text incl. style — the studio shows/edits exactly what
|
|
14
|
+
* generates. On a DERIVED asset it is the EDIT instruction. */
|
|
14
15
|
prompt: string
|
|
15
16
|
label?: string
|
|
17
|
+
/** derived asset: parent asset ids whose PICKED rolls feed generation as
|
|
18
|
+
* context images (gemini kinds only). Parent re-pick auto-regens. */
|
|
19
|
+
from?: string[]
|
|
16
20
|
/** freeform data passed through to the ship hook */
|
|
17
21
|
ship?: unknown
|
|
18
22
|
}
|
|
@@ -23,10 +27,13 @@ export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSp
|
|
|
23
27
|
* manual asset with no rolls is RED + FAILs `shipkit assets check`. The
|
|
24
28
|
* asset's `prompt` is the curation brief. */
|
|
25
29
|
export type ManualGen = { backend: 'manual' }
|
|
26
|
-
|
|
30
|
+
/** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
|
|
31
|
+
export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
|
|
32
|
+
export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
|
|
27
33
|
export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
|
|
28
34
|
export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
|
|
29
35
|
export const manual = (): ManualGen => ({ backend: 'manual' })
|
|
36
|
+
export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
|
|
30
37
|
|
|
31
38
|
export type Media = 'image' | 'audio'
|
|
32
39
|
export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
|
|
@@ -130,6 +137,9 @@ export function validateKit(k: KitConfig): string[] {
|
|
|
130
137
|
errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
|
|
131
138
|
if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
|
|
132
139
|
errs.push(`kind '${kind.id}': audio kinds take no geometry panel`)
|
|
140
|
+
for (const a of kind.assets ?? [])
|
|
141
|
+
if ((a.from?.length ?? 0) > 0 && kind.generate?.backend !== 'gemini')
|
|
142
|
+
errs.push(`kind '${kind.id}': asset '${a.id}' derives (from:) but the backend is '${kind.generate?.backend}' — only gemini() takes context images`)
|
|
133
143
|
if (kind.ship?.fn != null && kind.ship?.via != null) errs.push(`kind '${kind.id}': ship.fn and ship.via are two hooks for one pick — keep one`)
|
|
134
144
|
if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
|
|
135
145
|
for (const a of kind.assets ?? []) {
|
|
@@ -148,6 +158,19 @@ export function validateKit(k: KitConfig): string[] {
|
|
|
148
158
|
errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
|
|
149
159
|
}
|
|
150
160
|
}
|
|
161
|
+
// derived-asset graph: every from-ref must exist, and the graph is a DAG
|
|
162
|
+
const fromOf = new Map<string, string[]>()
|
|
163
|
+
for (const kind of k.kinds ?? []) for (const a of kind.assets ?? []) fromOf.set(a.id, a.from ?? [])
|
|
164
|
+
for (const [id, refs] of fromOf) for (const r of refs) if (!fromOf.has(r)) errs.push(`asset '${id}' derives from unknown asset '${r}'`)
|
|
165
|
+
const state = new Map<string, 'visiting' | 'done'>()
|
|
166
|
+
const visit = (id: string, path: string[]): void => {
|
|
167
|
+
if (state.get(id) === 'done') return
|
|
168
|
+
if (state.get(id) === 'visiting') { errs.push(`derived-asset cycle: ${[...path, id].join(' → ')}`); return }
|
|
169
|
+
state.set(id, 'visiting')
|
|
170
|
+
for (const r of fromOf.get(id) ?? []) if (fromOf.has(r)) visit(r, [...path, id])
|
|
171
|
+
state.set(id, 'done')
|
|
172
|
+
}
|
|
173
|
+
for (const id of fromOf.keys()) visit(id, [])
|
|
151
174
|
return errs
|
|
152
175
|
}
|
|
153
176
|
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync } from 'fs'
|
|
|
5
5
|
import { join, relative } from 'path'
|
|
6
6
|
import { randomSeed } from './gen/comfy-gen.ts'
|
|
7
7
|
import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
|
|
8
|
+
import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
|
|
8
9
|
import { loadWorkflows } from './gen/workflow-loader.ts'
|
|
9
10
|
import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
|
|
10
11
|
import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
|
|
@@ -28,11 +29,19 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
|
|
|
28
29
|
if (f == null) die(`unknown asset '${assetId}'`)
|
|
29
30
|
const dir = assetDir(join(root, kit.workspace), assetId)
|
|
30
31
|
const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
|
|
32
|
+
// derived assets need their parents' picks as context — a blocker names
|
|
33
|
+
// the parent and fails the whole asset, never a partial
|
|
34
|
+
let context: ParentContext | undefined
|
|
35
|
+
if (parentIdsOf(f!).length > 0) {
|
|
36
|
+
const r = await resolveParentContext(kit, join(root, kit.workspace), f!)
|
|
37
|
+
if ('missing' in r) { io.printErr(`FAIL: ${assetId} blocked — parents not ready: ${r.missing.join(', ')}`); return count }
|
|
38
|
+
context = r
|
|
39
|
+
}
|
|
31
40
|
let failed = 0
|
|
32
41
|
for (let i = 0; i < count; i++) {
|
|
33
42
|
const seed = randomSeed()
|
|
34
43
|
try {
|
|
35
|
-
await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
|
|
44
|
+
await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
|
|
36
45
|
io.print(`ok: ${assetId} v${seed}`)
|
|
37
46
|
} catch (e) {
|
|
38
47
|
failed++
|
|
@@ -70,12 +79,12 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
|
|
|
70
79
|
const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
|
|
71
80
|
const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
|
|
72
81
|
if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
// topo (parents-first) replaced sparsest-first: derived children need
|
|
83
|
+
// their parents' rolls to exist before they can ever generate
|
|
84
|
+
const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
|
|
75
85
|
.map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
|
|
76
86
|
.map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
|
|
77
87
|
.filter((t) => t.want > 0)
|
|
78
|
-
.sort((a, b) => a.have - b.have) // sparsest first
|
|
79
88
|
let failed = 0
|
|
80
89
|
for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
|
|
81
90
|
io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
|
|
@@ -89,26 +98,57 @@ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n
|
|
|
89
98
|
return failed > 0 ? 1 : 0
|
|
90
99
|
})
|
|
91
100
|
|
|
101
|
+
// ── identity: ONE render path + a brand.ts content hash so drift is
|
|
102
|
+
// machine-detectable — and machine-CLEARED (auto re-render), never a nag.
|
|
103
|
+
// Only a missing PICK still asks a human (taste).
|
|
104
|
+
const brandHashPath = (root: string): string => join(root, '.rv-identity/brand.hash')
|
|
105
|
+
const brandHash = async (root: string): Promise<string> =>
|
|
106
|
+
new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.rv-identity/brand.ts')).text()).digest('hex').slice(0, 16)
|
|
107
|
+
|
|
108
|
+
export type IdentityRender = { written: string[] } | { blocked: string }
|
|
109
|
+
|
|
110
|
+
export async function renderIdentityNow(root: string): Promise<IdentityRender> {
|
|
111
|
+
const brandPath = join(root, '.rv-identity/brand.ts')
|
|
112
|
+
if (!existsSync(brandPath)) return { blocked: 'no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
|
|
113
|
+
// cache-bust: bun caches dynamic imports for the process lifetime — a
|
|
114
|
+
// long-running hub must re-render the EDITED brand, not the cached one
|
|
115
|
+
const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
|
|
116
|
+
if (mod.default == null) return { blocked: '.rv-identity/brand.ts must default-export the Brand' }
|
|
117
|
+
const brand = mod.default
|
|
118
|
+
let markFile: string | undefined
|
|
119
|
+
if (brand.mark.backend === 'comfy') {
|
|
120
|
+
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
121
|
+
// owns the workspace where picks live
|
|
122
|
+
let kit: ResolvedKit
|
|
123
|
+
try { kit = await loadKit(root) } catch (e) { return { blocked: `comfy mark needs the kit's roll workspace — ${(e as Error).message}` } }
|
|
124
|
+
const dir = assetDir(join(root, kit.workspace), 'identity-logo')
|
|
125
|
+
const pick = await readPick(dir)
|
|
126
|
+
if (pick.pick == null) return { blocked: `brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}` }
|
|
127
|
+
markFile = join(dir, pick.pick)
|
|
128
|
+
if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
|
|
129
|
+
}
|
|
130
|
+
const written = await renderIdentity(brand, root, { markFile })
|
|
131
|
+
await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
|
|
132
|
+
return { written }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** brand.ts drifted since the last render → re-render NOW (autonomous).
|
|
136
|
+
* Returns what happened; 'blocked' = only a human pick can clear it. */
|
|
137
|
+
export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
|
|
138
|
+
if (!existsSync(join(root, '.rv-identity/brand.ts'))) return { state: 'absent' }
|
|
139
|
+
const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
|
|
140
|
+
const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
|
|
141
|
+
if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
|
|
142
|
+
const r = await renderIdentityNow(root)
|
|
143
|
+
if ('blocked' in r) return { state: 'blocked', detail: r.blocked }
|
|
144
|
+
return { state: 'rendered', detail: r.written.join(', ') }
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
|
|
93
148
|
guarded(io, async () => {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
const
|
|
97
|
-
if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
|
|
98
|
-
const brand = mod.default!
|
|
99
|
-
let markFile: string | undefined
|
|
100
|
-
if (brand.mark.backend === 'comfy') {
|
|
101
|
-
// comfy marks render from the PICKED identity-logo roll — the kit
|
|
102
|
-
// owns the workspace where picks live
|
|
103
|
-
const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
|
|
104
|
-
const dir = assetDir(join(root, kit.workspace), 'identity-logo')
|
|
105
|
-
const pick = await readPick(dir)
|
|
106
|
-
if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
|
|
107
|
-
markFile = join(dir, pick.pick!)
|
|
108
|
-
if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
|
|
109
|
-
}
|
|
110
|
-
const written = await renderIdentity(brand, root, { markFile })
|
|
111
|
-
for (const w of written) io.print(`ok: ${w}`)
|
|
149
|
+
const r = await renderIdentityNow(root)
|
|
150
|
+
if ('blocked' in r) die(r.blocked)
|
|
151
|
+
for (const w of (r as { written: string[] }).written) io.print(`ok: ${w}`)
|
|
112
152
|
return 0
|
|
113
153
|
})
|
|
114
154
|
|
|
@@ -137,11 +177,12 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
|
|
|
137
177
|
const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
138
178
|
io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
|
|
139
179
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
180
|
+
// machine-clearable red clears itself: drifted/missing identity
|
|
181
|
+
// outputs re-render HERE; only a missing pick (taste) reaches a human
|
|
182
|
+
const idn = await healIdentity(root)
|
|
183
|
+
if (idn.state === 'fresh') io.print('ok: identity outputs present + brand.ts unchanged')
|
|
184
|
+
else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
|
|
185
|
+
else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
|
|
145
186
|
// manual assets: no rolls = nothing to ship = RED (the missing list
|
|
146
187
|
// is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
|
|
147
188
|
const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
|
|
@@ -49,6 +49,7 @@ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid
|
|
|
49
49
|
.vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
|
|
50
50
|
.card .th .spin{position:absolute;top:8px;right:8px}
|
|
51
51
|
.dot.miss{background:#e5484d;border-color:#e5484d}
|
|
52
|
+
.dot.stale{background:#f5a524;border-color:#f5a524}
|
|
52
53
|
.vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
|
|
53
54
|
.vig.aud audio{width:100%;height:24px}
|
|
54
55
|
.stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
|
|
@@ -225,7 +226,7 @@ window.addEventListener('hashchange',restoreFromHash)
|
|
|
225
226
|
function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
|
|
226
227
|
function renderSide(list){
|
|
227
228
|
assets=list;$('#count').textContent=list.length
|
|
228
|
-
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
229
|
+
const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
|
|
229
230
|
if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
|
|
230
231
|
const cats=[...new Set(list.map(a=>a.cat))]
|
|
231
232
|
$('#list').innerHTML=cats.map(c=>{
|
|
@@ -236,7 +237,7 @@ function renderSide(list){
|
|
|
236
237
|
if(!open)return head
|
|
237
238
|
return head+list.filter(a=>a.cat===c).map(a=>
|
|
238
239
|
'<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
|
|
239
|
-
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
|
|
240
|
+
'</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
|
|
240
241
|
}).join('')
|
|
241
242
|
}
|
|
242
243
|
// ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
|
|
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
|
|
8
8
|
import { join, relative } from 'path'
|
|
9
9
|
import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
|
|
10
10
|
import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
|
|
11
|
+
import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
|
|
11
12
|
import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
|
|
12
13
|
import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
|
|
13
14
|
import { alphaHeight, toPng } from '../image-ops.ts'
|
|
@@ -20,15 +21,16 @@ export type StudioDeps = {
|
|
|
20
21
|
kit: ResolvedKit
|
|
21
22
|
root: string
|
|
22
23
|
page: () => string
|
|
23
|
-
/** test seam — defaults to the
|
|
24
|
-
|
|
24
|
+
/** test seam — defaults to the real engines. `context` = a derived
|
|
25
|
+
* asset's resolved parent picks (queue resolves it at run time) */
|
|
26
|
+
generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
|
|
28
30
|
|
|
29
31
|
// what the studio detail view shows about an asset's plumbing (W2-2)
|
|
30
32
|
export type AssetInfo = {
|
|
31
|
-
backend: 'canvas' | 'comfy' | 'manual'
|
|
33
|
+
backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
|
|
32
34
|
/** canvas rolls have no seed input — every roll is identical */
|
|
33
35
|
deterministic: boolean
|
|
34
36
|
/** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
|
|
@@ -100,15 +102,36 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
100
102
|
if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
|
|
101
103
|
return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
|
|
102
104
|
}
|
|
103
|
-
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
|
|
104
|
-
generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
|
|
105
|
+
const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
|
|
106
|
+
generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
|
|
105
107
|
|
|
106
108
|
const queue = new StudioQueue(async (job, seed, onProgress) => {
|
|
107
109
|
const found = findAsset(kit, job.assetId)
|
|
108
110
|
if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
|
|
109
|
-
|
|
111
|
+
// derived assets: parents' picks resolved at RUN time (a pick made
|
|
112
|
+
// while the job queued is honored); a blocker names the parent
|
|
113
|
+
let context: ParentContext | undefined
|
|
114
|
+
if (parentIdsOf(found).length > 0) {
|
|
115
|
+
const r = await resolveParentContext(kit, workspace, found)
|
|
116
|
+
if ('missing' in r) throw new Error(`'${job.assetId}' blocked — parents not ready: ${r.missing.join(', ')}`)
|
|
117
|
+
context = r
|
|
118
|
+
}
|
|
119
|
+
await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress, context)
|
|
110
120
|
}, randomSeed)
|
|
111
121
|
|
|
122
|
+
// machine-clearable red clears itself: re-picking a PARENT auto-enqueues
|
|
123
|
+
// every now-stale child (1 roll each) — no button, no nag. Generation
|
|
124
|
+
// spend is gated on the intentful pick, never on passive page loads.
|
|
125
|
+
const regenStaleChildren = async (parentId: string): Promise<void> => {
|
|
126
|
+
for (const f of allAssets(kit)) {
|
|
127
|
+
if (!parentIdsOf(f).includes(parentId)) continue
|
|
128
|
+
if (!(await isStale(kit, workspace, f))) continue
|
|
129
|
+
if (queue.pendingIds().includes(f.asset.id)) continue // already queued — never double-spend
|
|
130
|
+
queue.enqueue([{ assetId: f.asset.id, prompt: await promptOf(f), count: 1 }])
|
|
131
|
+
console.log(`ok: ${f.asset.id} auto-regen queued (parent '${parentId}' re-picked)`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
112
135
|
// ── session/live rect plumbing ─────────────────────────────────────────
|
|
113
136
|
const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
|
|
114
137
|
const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
|
|
@@ -207,6 +230,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
207
230
|
id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
|
|
208
231
|
prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
|
|
209
232
|
icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
|
|
233
|
+
derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
|
|
210
234
|
}
|
|
211
235
|
}))
|
|
212
236
|
return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
|
|
@@ -355,6 +379,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
|
|
|
355
379
|
s.pick = name
|
|
356
380
|
s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
|
|
357
381
|
})
|
|
382
|
+
void regenStaleChildren(f.asset.id).catch((e: Error) => queue.pushError(`auto-regen after '${f.asset.id}' pick: ${e.message}`))
|
|
358
383
|
return json(res)
|
|
359
384
|
} catch (e) {
|
|
360
385
|
queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The hub's React app (Bun.serve HTML import — no vite, no build step).
|
|
2
|
+
// Two views routed by pathname: / = attention dashboard (everything red),
|
|
3
|
+
// /status = STATUS.md + streamed check runs. The rail lists every mounted
|
|
4
|
+
// tool (from /hub.json) — legacy pages (graphs, studio) get the same rail
|
|
5
|
+
// injected server-side until they migrate.
|
|
6
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
+
import { observer } from 'mobx-react-lite'
|
|
8
|
+
import { StrictMode } from 'react'
|
|
9
|
+
import { createRoot } from 'react-dom/client'
|
|
10
|
+
|
|
11
|
+
type HubTool = { path: string; label: string; desc: string }
|
|
12
|
+
type AttentionLine = { text: string; href?: string }
|
|
13
|
+
type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
|
|
14
|
+
|
|
15
|
+
const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
|
|
16
|
+
|
|
17
|
+
class HubStore {
|
|
18
|
+
name = ''
|
|
19
|
+
tools: HubTool[] = []
|
|
20
|
+
sections: AttentionSection[] | null = null
|
|
21
|
+
statusMd: string | null = null
|
|
22
|
+
running = false
|
|
23
|
+
note = ''
|
|
24
|
+
error: string | null = null
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
makeAutoObservable(this)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get redCount(): number {
|
|
31
|
+
return (this.sections ?? []).filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async load(): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const hub = (await (await fetch('/hub.json')).json()) as { name: string; tools: HubTool[] }
|
|
37
|
+
const att = (await (await fetch('/api/attention.json')).json()) as { sections: AttentionSection[] }
|
|
38
|
+
runInAction(() => {
|
|
39
|
+
this.name = hub.name
|
|
40
|
+
this.tools = hub.tools
|
|
41
|
+
this.sections = att.sections
|
|
42
|
+
})
|
|
43
|
+
if (location.pathname === '/status') {
|
|
44
|
+
const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
|
|
45
|
+
runInAction(() => { this.statusMd = s.statusMd })
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
runInAction(() => { this.error = (e as Error).message })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async runCheck(): Promise<void> {
|
|
53
|
+
this.running = true
|
|
54
|
+
this.note = 'running…'
|
|
55
|
+
let text = ''
|
|
56
|
+
try {
|
|
57
|
+
const r = await fetch('/status/run', { method: 'POST' })
|
|
58
|
+
const rd = r.body!.getReader()
|
|
59
|
+
const dec = new TextDecoder()
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { done, value } = await rd.read()
|
|
62
|
+
if (done) break
|
|
63
|
+
text += dec.decode(value, { stream: true })
|
|
64
|
+
runInAction(() => { this.statusMd = text })
|
|
65
|
+
}
|
|
66
|
+
runInAction(() => { this.note = 'done — STATUS.md refreshed on disk' })
|
|
67
|
+
void this.load() // red counts moved — refresh the dashboard data
|
|
68
|
+
} catch (e) {
|
|
69
|
+
runInAction(() => {
|
|
70
|
+
this.statusMd = `${text}\nFAIL stream broke: ${(e as Error).message}`
|
|
71
|
+
this.note = 'stream broke'
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
runInAction(() => { this.running = false })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const store = new HubStore()
|
|
79
|
+
void store.load()
|
|
80
|
+
|
|
81
|
+
const Rail = observer(() => {
|
|
82
|
+
const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...store.tools.filter((t) => t.path !== '/')]
|
|
83
|
+
return (
|
|
84
|
+
<nav className="skrail">
|
|
85
|
+
{items.map((t) => {
|
|
86
|
+
const on = location.pathname === t.path || (t.path !== '/' && location.pathname.startsWith(t.path))
|
|
87
|
+
return (
|
|
88
|
+
<a key={t.path} className={on ? 'on' : ''} href={t.path} title={`${t.label} — ${t.desc}`}>
|
|
89
|
+
{RAIL_ICONS[t.path] ?? '·'}
|
|
90
|
+
{t.path === '/' && store.redCount > 0 ? <i /> : null}
|
|
91
|
+
</a>
|
|
92
|
+
)
|
|
93
|
+
})}
|
|
94
|
+
</nav>
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const Attention = observer(() => {
|
|
99
|
+
const live = (store.sections ?? []).filter((s) => s.lines.length > 0)
|
|
100
|
+
return (
|
|
101
|
+
<div className="wrap">
|
|
102
|
+
<div className="crumb">shipkit hub · attention</div>
|
|
103
|
+
<h1>{store.name}<small>everything that needs you, one page</small></h1>
|
|
104
|
+
{store.sections == null ? <div className="sub">loading…</div>
|
|
105
|
+
: live.length === 0 ? <div className="clear">✅ all clear — nothing needs you</div>
|
|
106
|
+
: live.map((s) => (
|
|
107
|
+
<section key={s.title} className={s.tone}>
|
|
108
|
+
<h2>{s.href != null ? <a href={s.href}>{s.title}</a> : s.title}<em>{s.lines.length}</em></h2>
|
|
109
|
+
{s.lines.map((l, i) => (
|
|
110
|
+
<div key={i} className="line">{l.href != null ? <a href={l.href}>{l.text}</a> : l.text}</div>
|
|
111
|
+
))}
|
|
112
|
+
</section>
|
|
113
|
+
))}
|
|
114
|
+
<div className="sub">refreshes on reload · checks refresh via <a href="/status">status → run check</a></div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const wireClass = (l: string): string => {
|
|
120
|
+
const t = l.trimStart()
|
|
121
|
+
if (t.startsWith('FAIL') || t.startsWith('✗')) return 'fail'
|
|
122
|
+
if (t.startsWith('WARN') || t.startsWith('⚠')) return 'warn'
|
|
123
|
+
if (t.startsWith('ok') || t.startsWith('✓')) return 'ok'
|
|
124
|
+
if (t.startsWith('skip') || t.startsWith('fix:') || t.startsWith('#')) return 'dim'
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const Status = observer(() => (
|
|
129
|
+
<div className="wrap">
|
|
130
|
+
<div className="crumb">shipkit hub · status</div>
|
|
131
|
+
<h1>{store.name}<small>STATUS.md</small></h1>
|
|
132
|
+
<div className="bar">
|
|
133
|
+
<button disabled={store.running} onClick={() => void store.runCheck()}>run check</button>
|
|
134
|
+
<span className="note">{store.note !== '' ? store.note : <>shells <code>shipkit check</code>, wire lines stream in below</>}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<pre>
|
|
137
|
+
{(store.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)')
|
|
138
|
+
.split('\n')
|
|
139
|
+
.map((l, i) => <span key={i} className={wireClass(l)}>{l}{'\n'}</span>)}
|
|
140
|
+
</pre>
|
|
141
|
+
</div>
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
const App = observer(() => (
|
|
145
|
+
<div className="app">
|
|
146
|
+
<Rail />
|
|
147
|
+
{store.error != null ? <div className="wrap err">hub api unreachable: {store.error}</div>
|
|
148
|
+
: location.pathname === '/status' ? <Status /> : <Attention />}
|
|
149
|
+
</div>
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
createRoot(document.getElementById('root')!).render(
|
|
153
|
+
<StrictMode>
|
|
154
|
+
<App />
|
|
155
|
+
</StrictMode>,
|
|
156
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* house palette — matches the studio/graph family. Plain CSS on purpose:
|
|
2
|
+
a published lib must not impose bunfig plugins (tailwind) on consumers. */
|
|
3
|
+
:root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
|
|
4
|
+
--accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
|
|
5
|
+
*{box-sizing:border-box}
|
|
6
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
|
|
7
|
+
.app{display:flex;min-height:100vh}
|
|
8
|
+
.wrap{max-width:900px;margin:0 auto;padding:34px 26px;flex:1}
|
|
9
|
+
h1{font-size:20px;margin:0}
|
|
10
|
+
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
|
|
11
|
+
.crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
|
|
12
|
+
|
|
13
|
+
.skrail{position:sticky;top:0;align-self:flex-start;height:100vh;width:46px;display:flex;flex-direction:column;
|
|
14
|
+
align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid var(--line);flex:none}
|
|
15
|
+
.skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
|
|
16
|
+
font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
|
|
17
|
+
.skrail a:hover{background:var(--panel2);filter:none;opacity:1}
|
|
18
|
+
.skrail a.on{background:var(--panel2);border-left-color:var(--accent);filter:none;opacity:1}
|
|
19
|
+
.skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:var(--red)}
|
|
20
|
+
|
|
21
|
+
.clear{margin-top:30px;font-size:17px;color:var(--teal)}
|
|
22
|
+
section{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:12px;
|
|
23
|
+
padding:14px 18px;margin-top:16px}
|
|
24
|
+
section.red{border-left-color:var(--red)}
|
|
25
|
+
h2{font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:8px}
|
|
26
|
+
h2 a{color:var(--ink);text-decoration:none}h2 a:hover{color:var(--gold)}
|
|
27
|
+
h2 em{font-style:normal;font-size:12px;color:var(--dim);background:var(--panel2);border-radius:8px;padding:1px 8px}
|
|
28
|
+
.line{font:12.5px/1.7 ui-monospace,Menlo,monospace;color:var(--dim);white-space:pre-wrap}
|
|
29
|
+
.line a{color:var(--dim);text-decoration:underline dotted}.line a:hover{color:var(--gold)}
|
|
30
|
+
section.red .line{color:#e8a0a0}
|
|
31
|
+
.sub{margin-top:10px;font-size:12px;color:var(--dim)}
|
|
32
|
+
.sub a{color:var(--dim)}
|
|
33
|
+
|
|
34
|
+
.bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
|
|
35
|
+
button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
|
|
36
|
+
button:disabled{opacity:.45;cursor:default}
|
|
37
|
+
.note{font-size:12px;color:var(--dim)}
|
|
38
|
+
pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
|
|
39
|
+
font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
|
|
40
|
+
.ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
|
|
41
|
+
.err{margin-top:26px;color:var(--red)}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>shipkit hub</title>
|
|
7
|
+
<link rel="stylesheet" href="./hub.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./hub-app.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/src/serve/hub-pages.ts
CHANGED
|
@@ -1,84 +1,45 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Shared hub-page plumbing. The hub UI itself is the Bun-native React app
|
|
2
|
+
// in hub-app/ (HTML import, served by serve-hub.ts — build/webapp
|
|
3
|
+
// doctrine). This module keeps what the LEGACY pages (graph pages, studio
|
|
4
|
+
// SPA) still need until they migrate: the server-injected rail, and the
|
|
5
|
+
// attention section types the collector + React app share.
|
|
5
6
|
import type { HubTool } from './serve-hub.ts'
|
|
6
7
|
|
|
7
|
-
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<')
|
|
8
|
+
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"')
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
:
|
|
11
|
-
--accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
|
|
12
|
-
*{box-sizing:border-box}
|
|
13
|
-
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
|
|
14
|
-
.wrap{max-width:860px;margin:0 auto;padding:40px 24px}
|
|
15
|
-
h1{font-size:20px;margin:0}
|
|
16
|
-
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
|
|
17
|
-
.crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
|
|
18
|
-
.crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
|
|
19
|
-
`
|
|
10
|
+
// ── the rail: thin fixed tab bar, injected into legacy hub-served pages ───
|
|
11
|
+
const RAIL_ICONS: Record<string, string> = { '/': '⚡', '/graph/wiki': '🕸', '/graph/agent': '🤖', '/assets/': '🖼', '/status': '📊' }
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
padding:16px;text-decoration:none;color:var(--ink)}
|
|
31
|
-
.card:hover{border-color:var(--accent);background:var(--panel2)}
|
|
32
|
-
.card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
|
|
33
|
-
.card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
|
|
34
|
-
</style></head><body><div class=wrap>
|
|
35
|
-
<div class=crumb>shipkit hub</div>
|
|
36
|
-
<h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
|
|
37
|
-
<div class=cards>
|
|
38
|
-
${cards}
|
|
39
|
-
</div>
|
|
40
|
-
</div></body></html>`
|
|
41
|
-
}
|
|
13
|
+
const RAIL_CSS = `
|
|
14
|
+
.skrail{position:fixed;left:0;top:0;bottom:0;width:46px;z-index:9999;display:flex;flex-direction:column;
|
|
15
|
+
align-items:center;padding-top:10px;gap:4px;background:#17140f;border-right:1px solid #332b22}
|
|
16
|
+
.skrail a{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:9px;
|
|
17
|
+
font-size:17px;text-decoration:none;border-left:2px solid transparent;filter:grayscale(35%);opacity:.75;position:relative}
|
|
18
|
+
.skrail a:hover{background:#262019;filter:none;opacity:1}
|
|
19
|
+
.skrail a.on{background:#262019;border-left-color:#e88c28;filter:none;opacity:1}
|
|
20
|
+
.skrail a i{position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05252}
|
|
21
|
+
body{padding-left:46px}`
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
|
|
59
|
-
<pre id=out></pre>
|
|
60
|
-
<script>
|
|
61
|
-
const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
|
|
62
|
-
// wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
|
|
63
|
-
const esc=s=>s.replace(/&/g,'&').replace(/</g,'<')
|
|
64
|
-
const cls=l=>{const t=l.trimStart()
|
|
65
|
-
if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
|
|
66
|
-
if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
|
|
67
|
-
if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
|
|
68
|
-
if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
|
|
69
|
-
return ''}
|
|
70
|
-
const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
|
|
71
|
-
paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
|
|
72
|
-
btn.onclick=async()=>{
|
|
73
|
-
btn.disabled=true;note.textContent='running…';let text=''
|
|
74
|
-
try{
|
|
75
|
-
const r=await fetch('status/run',{method:'POST'})
|
|
76
|
-
const rd=r.body.getReader(),dec=new TextDecoder()
|
|
77
|
-
for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
|
|
78
|
-
note.textContent='done — STATUS.md refreshed on disk'
|
|
79
|
-
}catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
|
|
80
|
-
btn.disabled=false
|
|
81
|
-
}
|
|
82
|
-
</script>
|
|
83
|
-
</div></body></html>`
|
|
23
|
+
/** Inject the rail into a legacy hub-served HTML document (post-<body>,
|
|
24
|
+
* fixed position — the page needs no layout knowledge). The React app
|
|
25
|
+
* renders its own identical rail from /hub.json. */
|
|
26
|
+
export function withRail(html: string, o: { tools: HubTool[]; active: string }): string {
|
|
27
|
+
const items = [{ path: '/', label: 'attention', desc: 'everything red' }, ...o.tools.filter((t) => t.path !== '/')]
|
|
28
|
+
.map((t) => {
|
|
29
|
+
const on = o.active === t.path || (t.path !== '/' && o.active.startsWith(t.path))
|
|
30
|
+
const dot = t.path === '/' ? '<i id=skdot style="display:none"></i>' : ''
|
|
31
|
+
return `<a class="${on ? 'on' : ''}" href="${esc(t.path)}" title="${esc(t.label)} — ${esc(t.desc)}">${RAIL_ICONS[t.path] ?? '·'}${dot}</a>`
|
|
32
|
+
})
|
|
33
|
+
.join('')
|
|
34
|
+
// the red dot fetches client-side: a passive page nav must never run the
|
|
35
|
+
// attention collector server-side (cost + heal side-effects)
|
|
36
|
+
const dotScript = `<script>fetch('/api/attention.json').then(r=>r.json()).then(j=>{const n=(j.sections||[]).filter(s=>s.tone==='red').reduce((a,s)=>a+s.lines.length,0);if(n>0)document.getElementById('skdot').style.display=''}).catch(()=>{})</script>`
|
|
37
|
+
return html.replace(/<body([^>]*)>/, `<body$1><style>${RAIL_CSS}</style><nav class=skrail>${items}</nav>${dotScript}`)
|
|
84
38
|
}
|
|
39
|
+
|
|
40
|
+
// ── attention data shapes (collector in run-serve, rendering in hub-app) ──
|
|
41
|
+
export type AttentionLine = { text: string; href?: string }
|
|
42
|
+
export type AttentionSection = { title: string; tone: 'red' | 'warn'; lines: AttentionLine[]; href?: string }
|
|
43
|
+
|
|
44
|
+
export const attentionRedCount = (sections: AttentionSection[]): number =>
|
|
45
|
+
sections.filter((s) => s.tone === 'red').reduce((n, s) => n + s.lines.length, 0)
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -5,14 +5,20 @@
|
|
|
5
5
|
// kit.ts loads. Port: --port > serve.port > a deterministic house-range
|
|
6
6
|
// default (4800-4979, hashed from the repo name — stable across launches;
|
|
7
7
|
// pin serve.port when two repos collide).
|
|
8
|
-
import { existsSync } from 'fs'
|
|
8
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { fileURLToPath } from 'url'
|
|
11
|
-
import { loadKit } from '../assets/kit-config.ts'
|
|
11
|
+
import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
|
|
12
|
+
import { isStale } from '../assets/gen/derived.ts'
|
|
13
|
+
import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
|
|
14
|
+
import { healIdentity } from '../assets/run-assets.ts'
|
|
12
15
|
import { page as studioPage } from '../assets/server/studio-page.ts'
|
|
13
16
|
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
14
|
-
import {
|
|
17
|
+
import { openAsks } from '../asks/asks-queue.ts'
|
|
18
|
+
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
19
|
+
import { activeContracts, contractsConfigFrom, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
15
20
|
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
21
|
+
import type { AttentionSection } from './hub-pages.ts'
|
|
16
22
|
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
17
23
|
|
|
18
24
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
@@ -32,12 +38,15 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
32
38
|
? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
|
|
33
39
|
: null
|
|
34
40
|
let studio: StudioRoutes | null = null
|
|
41
|
+
let kit: ResolvedKit | null = null
|
|
35
42
|
if (existsSync(join(root, '.rv-assets/kit.ts'))) {
|
|
36
43
|
// a broken kit must not take the graphs and status down with it —
|
|
37
44
|
// WARN + mount everything else
|
|
38
45
|
try {
|
|
39
|
-
|
|
46
|
+
kit = await loadKit(root)
|
|
47
|
+
studio = await studioRoutes({ kit, root, page: studioPage })
|
|
40
48
|
} catch (e) {
|
|
49
|
+
kit = null
|
|
41
50
|
io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -50,6 +59,76 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
|
|
|
50
59
|
agentGraph,
|
|
51
60
|
studio,
|
|
52
61
|
checkCmd: [process.execPath, cli, 'check'],
|
|
62
|
+
attention: attentionCollector(root, c, kit),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── the / dashboard's data: everything red, one collector per source, each
|
|
67
|
+
// failure-isolated (a broken source reports itself, never blanks the page)
|
|
68
|
+
export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
|
|
69
|
+
const guard = async (title: string, tone: 'red' | 'warn', body: () => Promise<AttentionSection['lines']>): Promise<AttentionSection> => {
|
|
70
|
+
try { return { title, tone, lines: await body() } } catch (e) { return { title, tone: 'red', lines: [{ text: `collector broke: ${(e as Error).message}` }] } }
|
|
71
|
+
}
|
|
72
|
+
return async () => {
|
|
73
|
+
const sections = await Promise.all([
|
|
74
|
+
guard('failing checks', 'red', async () => {
|
|
75
|
+
const p = join(root, 'STATUS.md')
|
|
76
|
+
if (!existsSync(p)) return []
|
|
77
|
+
// a cred FAIL whose ask already sits in the asks section is
|
|
78
|
+
// ONE action shown twice — the ask (his hands) wins
|
|
79
|
+
const askDir = join(root, c.asks?.dir ?? 'asks')
|
|
80
|
+
const askText = existsSync(askDir) ? openAsks(askDir).map((a) => a.title.toLowerCase()).join(' ') : ''
|
|
81
|
+
return (await Bun.file(p).text()).split('\n')
|
|
82
|
+
.filter((l) => l.trimStart().startsWith('FAIL'))
|
|
83
|
+
.filter((l) => {
|
|
84
|
+
const cred = l.match(/cred rv\/([a-z0-9-]+)\/[a-z0-9-]+ missing/)?.[1]
|
|
85
|
+
return cred == null || !askText.includes(cred)
|
|
86
|
+
})
|
|
87
|
+
.map((text) => ({ text, href: '/status' }))
|
|
88
|
+
}),
|
|
89
|
+
guard('asks — waiting on you', 'red', async () => {
|
|
90
|
+
const dir = join(root, c.asks?.dir ?? 'asks')
|
|
91
|
+
if (!existsSync(dir)) return [] // read path must not mint an asks/ dir
|
|
92
|
+
return openAsks(dir).map((a) => ({ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}` }))
|
|
93
|
+
}),
|
|
94
|
+
guard('assets', 'red', async () => {
|
|
95
|
+
// self-heal first: identity re-renders itself on brand.ts
|
|
96
|
+
// drift; only pick-blocked (taste) surfaces below
|
|
97
|
+
const idn = await healIdentity(root)
|
|
98
|
+
const lines: AttentionSection['lines'] = []
|
|
99
|
+
if (idn.state === 'blocked') lines.push({ text: `identity — ${idn.detail ?? 'blocked'}`, href: '/assets/#identity-logo' })
|
|
100
|
+
if (kit == null) return lines
|
|
101
|
+
for (const f of allAssets(kit)) {
|
|
102
|
+
const dir = assetDir(join(root, kit.workspace), f.asset.id)
|
|
103
|
+
const rolls = listRolls(dir)
|
|
104
|
+
const manual = f.kind.generate.backend === 'manual'
|
|
105
|
+
if (manual && rolls.length === 0) lines.push({ text: `${f.asset.id} — manual, nothing uploaded`, href: `/assets/#${f.asset.id}` })
|
|
106
|
+
else if (rolls.length > 0 && (await readPick(dir)).pick == null) lines.push({ text: `${f.asset.id} — ${rolls.length} roll(s), none picked`, href: `/assets/#${f.asset.id}` })
|
|
107
|
+
else if (await isStale(kit, join(root, kit.workspace), f)) lines.push({ text: `${f.asset.id} — stale (a parent was re-picked)`, href: `/assets/#${f.asset.id}` })
|
|
108
|
+
}
|
|
109
|
+
return lines
|
|
110
|
+
}),
|
|
111
|
+
guard('contracts', 'red', async () => {
|
|
112
|
+
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
|
|
113
|
+
return r.lines.filter((l) => l.kind === 'fail').map((l) => ({ text: l.text }))
|
|
114
|
+
}),
|
|
115
|
+
guard('contract warnings', 'warn', async () => {
|
|
116
|
+
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
|
|
117
|
+
return r.lines.filter((l) => l.kind === 'warn').map((l) => ({ text: l.text }))
|
|
118
|
+
}),
|
|
119
|
+
guard('posts to write', 'warn', async () => {
|
|
120
|
+
const social = join(root, '.rv-social')
|
|
121
|
+
if (!existsSync(social)) return []
|
|
122
|
+
const lines: AttentionSection['lines'] = []
|
|
123
|
+
for (const ch of readdirSync(social)) {
|
|
124
|
+
const d = join(social, ch)
|
|
125
|
+
if (!statSync(d).isDirectory()) continue
|
|
126
|
+
for (const f of readdirSync(d).filter((x) => x.endsWith('.md'))) lines.push({ text: `${ch}/${f}` })
|
|
127
|
+
}
|
|
128
|
+
return lines
|
|
129
|
+
}),
|
|
130
|
+
])
|
|
131
|
+
return sections
|
|
53
132
|
}
|
|
54
133
|
}
|
|
55
134
|
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -9,7 +9,11 @@ import { join } from 'path'
|
|
|
9
9
|
import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
|
|
10
10
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
11
11
|
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
12
|
-
import {
|
|
12
|
+
import { withRail, type AttentionSection } from './hub-pages.ts'
|
|
13
|
+
// the hub UI is a Bun-native React app (HTML import, no vite, no build) —
|
|
14
|
+
// build/webapp doctrine; legacy pages (graphs, studio SPA) keep the
|
|
15
|
+
// server-injected rail until they migrate
|
|
16
|
+
import hubIndex from './hub-app/index.html'
|
|
13
17
|
|
|
14
18
|
export type HubGraph = { title: string; load: () => MdGraph }
|
|
15
19
|
export type HubTool = { path: string; label: string; desc: string }
|
|
@@ -25,6 +29,9 @@ export type HubDeps = {
|
|
|
25
29
|
studio: StudioRoutes | null
|
|
26
30
|
/** argv shelling the check run streamed by /status/run (test seam) */
|
|
27
31
|
checkCmd: string[]
|
|
32
|
+
/** the / dashboard's data — everything red, gathered per request
|
|
33
|
+
* (checks · asks · assets · contracts · posts; run-serve builds it) */
|
|
34
|
+
attention: () => Promise<AttentionSection[]>
|
|
28
35
|
}
|
|
29
36
|
export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
|
|
30
37
|
|
|
@@ -40,24 +47,33 @@ export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>
|
|
|
40
47
|
|
|
41
48
|
export function serveHub(d: HubDeps): Hub {
|
|
42
49
|
const tools = hubTools(d)
|
|
43
|
-
|
|
50
|
+
// every HTML page carries the rail — one keystroke between surfaces;
|
|
51
|
+
// the red dot self-fetches client-side (no collector on passive navs)
|
|
52
|
+
const shell = (s: string, active: string): Response =>
|
|
53
|
+
new Response(withRail(s, { tools, active }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
44
54
|
const server: ReturnType<typeof Bun.serve> = Bun.serve({
|
|
45
55
|
hostname: '127.0.0.1',
|
|
46
56
|
port: d.port,
|
|
57
|
+
// / (attention dashboard) and /status are the React app — one HTML
|
|
58
|
+
// bundle, view picked by pathname client-side
|
|
59
|
+
routes: { '/': hubIndex, '/status': hubIndex },
|
|
47
60
|
async fetch(req): Promise<Response> {
|
|
48
61
|
const path = new URL(req.url).pathname
|
|
49
|
-
if (path === '/') return html(landingPage({ name: d.name, port: server.port ?? d.port, tools }))
|
|
50
62
|
// identity probe: lets a second launch tell "same repo, another
|
|
51
63
|
// window" from "a DIFFERENT repo hashed onto my port"
|
|
52
64
|
if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
|
|
53
|
-
if (path === '/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (path === '/status') {
|
|
65
|
+
if (path === '/api/attention.json') {
|
|
66
|
+
const sections = await d.attention().catch((e: Error) => [{ title: 'attention collector broke', tone: 'red' as const, lines: [{ text: e.message }] }])
|
|
67
|
+
return Response.json({ sections })
|
|
68
|
+
}
|
|
69
|
+
if (path === '/api/status.json') {
|
|
58
70
|
const p = join(d.root, 'STATUS.md')
|
|
59
|
-
return
|
|
71
|
+
return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
|
|
60
72
|
}
|
|
73
|
+
if (path === '/graph/wiki') return shell(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }), path)
|
|
74
|
+
if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
|
|
75
|
+
if (d.agentGraph != null && path === '/graph/agent') return shell(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }), path)
|
|
76
|
+
if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
|
|
61
77
|
if (path === '/status/run' && req.method === 'POST') {
|
|
62
78
|
// stream wire lines as the check emits them; sh merges stderr
|
|
63
79
|
// so FAIL lines reach the page, not just the terminal
|
|
@@ -68,6 +84,11 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
68
84
|
// the studio page fetches RELATIVE urls — it must live under
|
|
69
85
|
// the slash-terminated prefix for them to resolve
|
|
70
86
|
if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
|
|
87
|
+
if (path === '/assets/' && req.method === 'GET') {
|
|
88
|
+
// the SPA document gets the rail too; API routes untouched
|
|
89
|
+
const res = await d.studio.handle(req, '/')
|
|
90
|
+
return shell(await res.text(), '/assets/')
|
|
91
|
+
}
|
|
71
92
|
if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
|
|
72
93
|
}
|
|
73
94
|
return new Response('not found', { status: 404 })
|
package/src/shipkit.ts
CHANGED
|
@@ -6,7 +6,8 @@ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './re
|
|
|
6
6
|
export { serveHub, hubTools } from './serve/serve-hub.ts'
|
|
7
7
|
export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
|
|
8
8
|
export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
|
|
9
|
-
export {
|
|
9
|
+
export { withRail, attentionRedCount } from './serve/hub-pages.ts'
|
|
10
|
+
export type { AttentionSection, AttentionLine } from './serve/hub-pages.ts'
|
|
10
11
|
export { discoverChecks } from './core/discover-checks.ts'
|
|
11
12
|
export type { DiscoveredCheck } from './core/discover-checks.ts'
|
|
12
13
|
export { runTasks, selectTasks } from './core/task-runner.ts'
|
|
@@ -33,8 +34,8 @@ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
|
|
|
33
34
|
// assets surface (folded in from the standalone atelier lib; that npm
|
|
34
35
|
// package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
|
|
35
36
|
// declares its kinds; `shipkit assets …` derives the rest.
|
|
36
|
-
export { defineKit, defineKind, comfy, canvas, manual, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
|
|
37
|
-
export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
37
|
+
export { defineKit, defineKind, comfy, canvas, manual, gemini, mediaOf, MEDIA_EXTS, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
|
|
38
|
+
export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, GeminiGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
|
|
38
39
|
export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
|
|
39
40
|
export type { Brand } from './assets/kits/identity-kit.ts'
|
|
40
41
|
export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
|