@fabrika/builder 0.2.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/BUILD-CONTRACT.md +41 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/cli/src/bin.ts +66 -0
- package/cli/src/build-status.ts +109 -0
- package/cli/src/catalog.ts +42 -0
- package/cli/src/commands/advance.ts +73 -0
- package/cli/src/commands/build.ts +215 -0
- package/cli/src/commands/ensure-daemon.ts +25 -0
- package/cli/src/commands/gate.ts +165 -0
- package/cli/src/commands/induce.ts +61 -0
- package/cli/src/commands/init.ts +91 -0
- package/cli/src/commands/next.ts +31 -0
- package/cli/src/config.ts +47 -0
- package/cli/src/context.ts +28 -0
- package/cli/src/daemon-client.ts +280 -0
- package/cli/src/daemon-lifecycle.ts +171 -0
- package/cli/src/derive.ts +109 -0
- package/cli/src/index.ts +38 -0
- package/cli/src/induce.ts +151 -0
- package/cli/src/inject-skills.ts +313 -0
- package/cli/src/issues.ts +75 -0
- package/cli/src/layout.ts +124 -0
- package/cli/src/packet.ts +108 -0
- package/cli/src/phase-state.ts +19 -0
- package/cli/src/phases.ts +131 -0
- package/cli/src/skeletons.ts +104 -0
- package/daemon/src/bin.ts +70 -0
- package/daemon/src/build-stream.ts +38 -0
- package/daemon/src/handlers.ts +472 -0
- package/daemon/src/index.ts +123 -0
- package/daemon/src/paths.ts +30 -0
- package/daemon/src/phases.ts +221 -0
- package/daemon/src/static.ts +72 -0
- package/decisions/README.md +133 -0
- package/decisions/patterns/app-deploy.md +55 -0
- package/decisions/patterns/auth-password-kv.md +45 -0
- package/decisions/patterns/count-store.md +44 -0
- package/decisions/patterns/di-container.md +47 -0
- package/decisions/patterns/feature-architecture.md +48 -0
- package/decisions/patterns/lib-publish.md +46 -0
- package/decisions/patterns/monorepo.md +47 -0
- package/decisions/patterns/named-seeds.md +45 -0
- package/decisions/patterns/observability.md +45 -0
- package/decisions/patterns/single-worker-spa.md +74 -0
- package/decisions/patterns/structured-logging.md +49 -0
- package/decisions/patterns/typed-worker-rpc.md +87 -0
- package/decisions/patterns/vozka-app-deploy.md +74 -0
- package/decisions/patterns/write-split.md +47 -0
- package/decisions/technologies/analytics-engine.md +44 -0
- package/decisions/technologies/bindx.md +55 -0
- package/decisions/technologies/bun.md +51 -0
- package/decisions/technologies/buzola.md +80 -0
- package/decisions/technologies/cloudflare-access.md +50 -0
- package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
- package/decisions/technologies/cloudflare.md +50 -0
- package/decisions/technologies/contember.md +65 -0
- package/decisions/technologies/cron-triggers.md +46 -0
- package/decisions/technologies/d1.md +51 -0
- package/decisions/technologies/durable-objects.md +44 -0
- package/decisions/technologies/kv.md +42 -0
- package/decisions/technologies/lopata.md +55 -0
- package/decisions/technologies/nuasite.md +50 -0
- package/decisions/technologies/oblaka.md +48 -0
- package/decisions/technologies/opice.md +40 -0
- package/decisions/technologies/pletivo.md +53 -0
- package/decisions/technologies/postgres.md +47 -0
- package/decisions/technologies/preact.md +44 -0
- package/decisions/technologies/propustka.md +137 -0
- package/decisions/technologies/queue.md +55 -0
- package/decisions/technologies/r2.md +41 -0
- package/decisions/technologies/react-router.md +47 -0
- package/decisions/technologies/react.md +37 -0
- package/decisions/technologies/roj.md +51 -0
- package/decisions/technologies/sentry.md +49 -0
- package/decisions/technologies/tailwind.md +45 -0
- package/decisions/technologies/vite.md +47 -0
- package/decisions/technologies/vozka.md +122 -0
- package/decisions/technologies/workers-ai.md +59 -0
- package/decisions/technologies/wrangler.md +41 -0
- package/decisions/tooling/deptective.md +45 -0
- package/decisions/tooling/edvabe.md +45 -0
- package/index.ts +2 -0
- package/package.json +51 -0
- package/skill/SKILL.md +76 -0
- package/skill/agents/build-orchestrator.md +170 -0
- package/skill/agents/flows-author.md +65 -0
- package/skill/agents/implementer.md +37 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/agents/opice-skeleton-author.md +68 -0
- package/skill/gates/brief.jsx +20 -0
- package/skill/gates/build.jsx +20 -0
- package/skill/gates/design.jsx +22 -0
- package/skill/gates/flows.jsx +14 -0
- package/skill/gates/review-grid.jsx +32 -0
- package/skill/gates/tech.jsx +17 -0
- package/skill/gates/vision.jsx +27 -0
- package/stacks/README.md +44 -0
- package/stacks/cf-fat.md +64 -0
- package/stacks/cf-internal-app.md +125 -0
- package/stacks/cf-lightweight.md +54 -0
- package/stacks/contember-app.md +53 -0
- package/stacks/content-site.md +45 -0
- package/ui/dist/assets/annotations-runtime.js +1 -0
- package/ui/dist/assets/client.css +2 -0
- package/ui/dist/assets/client.fhjppkvr.js +214 -0
- package/ui/dist/assets/components.js +63 -0
- package/ui/dist/assets/preact-compat.js +1 -0
- package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
- package/ui/dist/assets/react-jsx-runtime.js +3 -0
- package/ui/dist/assets/runtime.js +1 -0
- package/ui/dist/index.html +36 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import type { Route } from '@fabrika/daemon-kit'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
4
|
+
import { basename, dirname, extname, join, resolve, sep } from 'path'
|
|
5
|
+
import type { BuildEvent, BuildStream } from './build-stream'
|
|
6
|
+
import { COMPILE_TEMP_DIR } from './paths'
|
|
7
|
+
import { derivePhaseState, type GateOracle } from './phases'
|
|
8
|
+
|
|
9
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
10
|
+
return new Response(JSON.stringify(data), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function errorMessage(error: unknown): string {
|
|
21
|
+
return error instanceof Error ? error.message : String(error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function routeParam(match: URLPatternResult, name: string): string {
|
|
25
|
+
const value = match.pathname.groups[name]
|
|
26
|
+
if (value === undefined) throw new Error(`Missing route parameter: ${name}`)
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function optionalString(value: unknown): string | undefined {
|
|
31
|
+
return typeof value === 'string' ? value : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isBuildStatus(value: unknown): value is BuildEvent['status'] {
|
|
35
|
+
return value === 'queued' || value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked'
|
|
36
|
+
|| value === 'info'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseBuildEvent(value: unknown): BuildEvent | null {
|
|
40
|
+
if (!isRecord(value) || !isBuildStatus(value['status'])) return null
|
|
41
|
+
const issue = optionalString(value['issue'])
|
|
42
|
+
const message = optionalString(value['message'])
|
|
43
|
+
const at = optionalString(value['at'])
|
|
44
|
+
const data = isRecord(value['data']) ? value['data'] : undefined
|
|
45
|
+
return {
|
|
46
|
+
status: value['status'],
|
|
47
|
+
...(issue === undefined ? {} : { issue }),
|
|
48
|
+
...(message === undefined ? {} : { message }),
|
|
49
|
+
...(at === undefined ? {} : { at }),
|
|
50
|
+
...(data === undefined ? {} : { data }),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function corsHeaders(port: number): Record<string, string> {
|
|
55
|
+
return {
|
|
56
|
+
'Access-Control-Allow-Origin': `http://localhost:${port}`,
|
|
57
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
58
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HandlerContext {
|
|
63
|
+
sessionManager: SessionManager
|
|
64
|
+
broadcastPlanUpdate: (id: string) => void
|
|
65
|
+
broadcastRevisionUpdate: (id: string) => void
|
|
66
|
+
buildStream: BuildStream
|
|
67
|
+
sessionsDir: string
|
|
68
|
+
port: number
|
|
69
|
+
version: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function gateOracleFor(sm: SessionManager): GateOracle {
|
|
73
|
+
return {
|
|
74
|
+
getLatestUnconsumedFeedback: (id) => sm.getLatestUnconsumedFeedback(id),
|
|
75
|
+
hasGateRevision: (id, gateName) => {
|
|
76
|
+
const session = sm.get(id)
|
|
77
|
+
if (!session) return false
|
|
78
|
+
for (let i = session.revisions.length - 1; i >= 0; i--) {
|
|
79
|
+
const r = session.revisions[i]!
|
|
80
|
+
if (r.label === gateName) return r.hasFeedback
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface CanvasSelection {
|
|
88
|
+
files: Map<string, string>
|
|
89
|
+
scope: CanvasScope
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection {
|
|
93
|
+
const view = typeof body['view'] === 'string' ? body['view'] : null
|
|
94
|
+
const directory = typeof body['directory'] === 'string' ? body['directory'] : null
|
|
95
|
+
if (!!view === !!directory) {
|
|
96
|
+
throw new Error('Provide exactly one of view or directory')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (view) {
|
|
100
|
+
if (!existsSync(view) || !statSync(view).isFile()) {
|
|
101
|
+
throw new Error(`Canvas view not found: ${view}`)
|
|
102
|
+
}
|
|
103
|
+
if (extname(view).toLowerCase() !== '.jsx') {
|
|
104
|
+
throw new Error(`Canvas view must be a .jsx file: ${view}`)
|
|
105
|
+
}
|
|
106
|
+
const filename = basename(view)
|
|
107
|
+
return {
|
|
108
|
+
files: new Map([[filename, readFileSync(view, 'utf-8')]]),
|
|
109
|
+
scope: { kind: 'view', filename },
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (directory === null) throw new Error('Canvas directory is missing')
|
|
114
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory()) {
|
|
115
|
+
throw new Error(`Canvas directory not found: ${directory}`)
|
|
116
|
+
}
|
|
117
|
+
const filenames = readdirSync(directory).filter((f) => f.endsWith('.jsx')).sort()
|
|
118
|
+
if (filenames.length === 0) throw new Error('No .jsx files found in directory')
|
|
119
|
+
const files = new Map<string, string>()
|
|
120
|
+
for (const filename of filenames) {
|
|
121
|
+
files.set(filename, readFileSync(join(directory, filename), 'utf-8'))
|
|
122
|
+
}
|
|
123
|
+
return { files, scope: { kind: 'directory' } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createHandlers(ctx: HandlerContext): Route[] {
|
|
127
|
+
const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, buildStream } = ctx
|
|
128
|
+
|
|
129
|
+
function handleHealth(): Response {
|
|
130
|
+
return jsonResponse({
|
|
131
|
+
ok: true,
|
|
132
|
+
version: ctx.version,
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
dataDir: dirname(ctx.sessionsDir),
|
|
135
|
+
sessionsDir: ctx.sessionsDir,
|
|
136
|
+
sessions: sessionManager.list().map((s) => s.id),
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handlePhase(_req: Request, url: URL): Response {
|
|
141
|
+
const root = url.searchParams.get('root')
|
|
142
|
+
if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
|
|
143
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
144
|
+
try {
|
|
145
|
+
const state = derivePhaseState(root, sessionId, gateOracleFor(sessionManager))
|
|
146
|
+
return jsonResponse(state)
|
|
147
|
+
} catch (error: unknown) {
|
|
148
|
+
return jsonResponse({ error: errorMessage(error) }, 500)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
153
|
+
const sessionId = routeParam(match, 'id')
|
|
154
|
+
try {
|
|
155
|
+
const parsedBody: unknown = await req.json()
|
|
156
|
+
if (!isRecord(parsedBody)) return jsonResponse({ error: 'Expected a JSON object' }, 400)
|
|
157
|
+
const body = parsedBody
|
|
158
|
+
let selection: CanvasSelection
|
|
159
|
+
try {
|
|
160
|
+
selection = resolveCanvasSelection(body)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400)
|
|
163
|
+
}
|
|
164
|
+
const canvasFiles = selection.files
|
|
165
|
+
|
|
166
|
+
const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId)
|
|
167
|
+
if (unconsumed) {
|
|
168
|
+
sessionManager.consumeFeedback(sessionId, unconsumed.revision)
|
|
169
|
+
return jsonResponse(
|
|
170
|
+
{
|
|
171
|
+
ok: false,
|
|
172
|
+
error: `Unconsumed feedback from revision ${unconsumed.revision}. Address the feedback before pushing a new canvas.`,
|
|
173
|
+
unconsumedFeedback: unconsumed.feedback,
|
|
174
|
+
unconsumedRevision: unconsumed.revision,
|
|
175
|
+
},
|
|
176
|
+
409,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const projectRoot = optionalString(body['projectRoot']) ?? process.cwd()
|
|
181
|
+
const isNew = !sessionManager.get(sessionId)
|
|
182
|
+
const gate = optionalString(body['gate'])
|
|
183
|
+
const label = gate ?? optionalString(body['label'])
|
|
184
|
+
const response = optionalString(body['response'])
|
|
185
|
+
|
|
186
|
+
const compiled = new Map<string, string>()
|
|
187
|
+
const errors: Record<string, string> = {}
|
|
188
|
+
await Promise.all(
|
|
189
|
+
[...canvasFiles.entries()].map(async ([filename, jsx]) => {
|
|
190
|
+
const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR })
|
|
191
|
+
if (result.ok) compiled.set(filename, result.js)
|
|
192
|
+
else errors[filename] = result.error
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
if (Object.keys(errors).length > 0) {
|
|
196
|
+
return jsonResponse({ ok: false, error: 'Canvas compilation failed', errors }, 400)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const session = sessionManager.upsert(
|
|
200
|
+
sessionId,
|
|
201
|
+
canvasFiles,
|
|
202
|
+
projectRoot,
|
|
203
|
+
label,
|
|
204
|
+
response,
|
|
205
|
+
selection.scope,
|
|
206
|
+
)
|
|
207
|
+
for (const [filename, js] of compiled) {
|
|
208
|
+
sessionManager.saveCompiled(sessionId, filename, js, session.currentRevision)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
broadcastPlanUpdate(sessionId)
|
|
212
|
+
watchSession(sessionId, sessionManager, broadcastPlanUpdate)
|
|
213
|
+
|
|
214
|
+
const browserUrl = `http://localhost:${ctx.port}/s/${sessionId}`
|
|
215
|
+
return jsonResponse({
|
|
216
|
+
ok: true,
|
|
217
|
+
browserUrl,
|
|
218
|
+
isNew,
|
|
219
|
+
revision: session.currentRevision,
|
|
220
|
+
sessionId,
|
|
221
|
+
...(gate === undefined ? {} : { gate }),
|
|
222
|
+
canvasFiles: [...canvasFiles.keys()],
|
|
223
|
+
})
|
|
224
|
+
} catch (error: unknown) {
|
|
225
|
+
return jsonResponse({ error: errorMessage(error) }, 500)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleCanvasJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
|
|
230
|
+
const sessionId = routeParam(match, 'id')
|
|
231
|
+
const jsFilename = routeParam(match, 'filename')
|
|
232
|
+
const jsxFilename = jsFilename.replace(/\.js$/, '.jsx')
|
|
233
|
+
const revParam = url.searchParams.get('rev')
|
|
234
|
+
const rev = revParam ? parseInt(revParam, 10) : undefined
|
|
235
|
+
|
|
236
|
+
let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev)
|
|
237
|
+
|
|
238
|
+
if (!compiled && rev) {
|
|
239
|
+
const session = sessionManager.get(sessionId)
|
|
240
|
+
if (session) {
|
|
241
|
+
const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename)
|
|
242
|
+
if (jsx) {
|
|
243
|
+
const result = await compileJsx(jsx, { projectRoot: session.projectRoot, tempDir: COMPILE_TEMP_DIR })
|
|
244
|
+
if (result.ok) {
|
|
245
|
+
sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev)
|
|
246
|
+
compiled = result.js
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!compiled) return jsonResponse({ error: 'No compiled canvas' }, 404)
|
|
253
|
+
return new Response(compiled, {
|
|
254
|
+
headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
259
|
+
const sessionId = routeParam(match, 'id')
|
|
260
|
+
const session = sessionManager.get(sessionId)
|
|
261
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
262
|
+
return jsonResponse({
|
|
263
|
+
projectRoot: session.projectRoot,
|
|
264
|
+
currentRevision: session.currentRevision,
|
|
265
|
+
revisions: session.revisions,
|
|
266
|
+
canvasFiles: session.canvasFiles,
|
|
267
|
+
createdAt: session.createdAt,
|
|
268
|
+
updatedAt: session.updatedAt,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function handleArtifact(_req: Request, url: URL, match: URLPatternResult): Response {
|
|
273
|
+
const sessionId = routeParam(match, 'id')
|
|
274
|
+
const session = sessionManager.get(sessionId)
|
|
275
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
276
|
+
|
|
277
|
+
const rel = url.searchParams.get('path')
|
|
278
|
+
if (!rel) return jsonResponse({ error: 'Missing ?path=<relative path>' }, 400)
|
|
279
|
+
|
|
280
|
+
const root = resolve(session.projectRoot)
|
|
281
|
+
const target = resolve(root, rel)
|
|
282
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
283
|
+
return jsonResponse({ error: 'Path escapes project root' }, 403)
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const st = statSync(target)
|
|
287
|
+
if (!st.isFile()) return jsonResponse({ error: 'Not a file' }, 404)
|
|
288
|
+
return jsonResponse({ path: rel, content: readFileSync(target, 'utf-8') })
|
|
289
|
+
} catch {
|
|
290
|
+
return jsonResponse({ error: 'Artifact not found' }, 404)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function handleSkeletons(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
295
|
+
const sessionId = routeParam(match, 'id')
|
|
296
|
+
const session = sessionManager.get(sessionId)
|
|
297
|
+
if (!session) return jsonResponse({ error: 'Session not found' }, 404)
|
|
298
|
+
const dir = join(session.projectRoot, 'tests', 'browser')
|
|
299
|
+
if (!existsSync(dir)) return jsonResponse({ skeletons: [] })
|
|
300
|
+
|
|
301
|
+
const matchBrace = (s: string, open: number): number => {
|
|
302
|
+
let depth = 0
|
|
303
|
+
for (let i = open; i < s.length; i++) {
|
|
304
|
+
if (s[i] === '{') depth++
|
|
305
|
+
else if (s[i] === '}' && --depth === 0) return i
|
|
306
|
+
}
|
|
307
|
+
return -1
|
|
308
|
+
}
|
|
309
|
+
const routePath = (u: string | undefined): string | undefined => {
|
|
310
|
+
if (!u) return undefined
|
|
311
|
+
let s = u.replace(/\$\{[^}]*\}/g, '').replace(/^https?:\/\/[^/]+/, '')
|
|
312
|
+
s = s.split('#')[0]!.split('?')[0]!
|
|
313
|
+
if (!s.startsWith('/')) s = '/' + s
|
|
314
|
+
return s.replace(/\/{2,}/g, '/') || '/'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const skeletons: unknown[] = []
|
|
318
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
319
|
+
if (!/\.test\.ts$/.test(entry)) continue
|
|
320
|
+
const src = readFileSync(join(dir, entry), 'utf-8')
|
|
321
|
+
const re = /browserTest\s*\(/g
|
|
322
|
+
let mm: RegExpExecArray | null
|
|
323
|
+
while ((mm = re.exec(src))) {
|
|
324
|
+
const objStart = src.indexOf('{', mm.index)
|
|
325
|
+
if (objStart === -1) continue
|
|
326
|
+
const objEnd = matchBrace(src, objStart)
|
|
327
|
+
if (objEnd === -1) continue
|
|
328
|
+
const meta = src.slice(objStart, objEnd + 1)
|
|
329
|
+
const str = (k: string) => meta.match(new RegExp(`\\b${k}\\s*:\\s*[\`'"]([^\`'"]*)`))?.[1]
|
|
330
|
+
const arr = (k: string) => {
|
|
331
|
+
const a = meta.match(new RegExp(`\\b${k}\\s*:\\s*\\[([\\s\\S]*?)\\]`))?.[1] ?? ''
|
|
332
|
+
return [...a.matchAll(/[`'"]([^`'"]+)[`'"]/g)].map((x) => x[1])
|
|
333
|
+
}
|
|
334
|
+
const after = src.slice(objEnd + 1)
|
|
335
|
+
const nextBT = after.search(/\bbrowserTest\s*\(/)
|
|
336
|
+
const body = nextBT === -1 ? after : after.slice(0, nextBT)
|
|
337
|
+
const steps = (body.match(/\bstep(?:\.\w+)?\s*\(/g) ?? []).length
|
|
338
|
+
const invariants = (body.match(/\binvariant(?:\.\w+)?\s*\(/g) ?? []).length
|
|
339
|
+
const pending = (body.match(/\bstep\s*\(\s*[`'"][^`'"]*[`'"]\s*,\s*\{[^}]*\}\s*\)/g) ?? []).length
|
|
340
|
+
+ (body.match(/\binvariant\.todo\s*\(/g) ?? []).length
|
|
341
|
+
skeletons.push({
|
|
342
|
+
file: entry,
|
|
343
|
+
name: str('name') ?? entry.replace(/\.test\.ts$/, ''),
|
|
344
|
+
url: routePath(str('url')),
|
|
345
|
+
feature: str('feature'),
|
|
346
|
+
seeds: arr('seeds'),
|
|
347
|
+
roles: arr('roles'),
|
|
348
|
+
steps,
|
|
349
|
+
invariants,
|
|
350
|
+
pending,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return jsonResponse({ skeletons })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handleFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
358
|
+
const sessionId = routeParam(match, 'id')
|
|
359
|
+
const rev = parseInt(routeParam(match, 'rev'), 10)
|
|
360
|
+
const feedback = sessionManager.getFeedback(sessionId, rev)
|
|
361
|
+
if (feedback === null) return jsonResponse({ error: 'No feedback' }, 404)
|
|
362
|
+
return jsonResponse({ feedback })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function handleFeedbackConsume(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
366
|
+
const sessionId = routeParam(match, 'id')
|
|
367
|
+
const result = sessionManager.getLatestUnconsumedFeedback(sessionId)
|
|
368
|
+
if (!result) return jsonResponse({ found: false })
|
|
369
|
+
sessionManager.consumeFeedback(sessionId, result.revision)
|
|
370
|
+
broadcastRevisionUpdate(sessionId)
|
|
371
|
+
return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleResponsesGet(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
375
|
+
const sessionId = routeParam(match, 'id')
|
|
376
|
+
const rev = parseInt(routeParam(match, 'rev'), 10)
|
|
377
|
+
const responses = sessionManager.getResponses(sessionId, rev)
|
|
378
|
+
return jsonResponse({ responses })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function handleResponsesPut(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
382
|
+
const sessionId = routeParam(match, 'id')
|
|
383
|
+
const rev = parseInt(routeParam(match, 'rev'), 10)
|
|
384
|
+
if (!sessionManager.get(sessionId)) return jsonResponse({ error: 'Session not found' }, 404)
|
|
385
|
+
try {
|
|
386
|
+
const body = await req.json()
|
|
387
|
+
sessionManager.saveResponses(sessionId, rev, body)
|
|
388
|
+
return jsonResponse({ ok: true })
|
|
389
|
+
} catch (error: unknown) {
|
|
390
|
+
return jsonResponse({ error: errorMessage(error) }, 400)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function handleSessionReset(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
395
|
+
const sessionId = routeParam(match, 'id')
|
|
396
|
+
const existed = !!sessionManager.get(sessionId)
|
|
397
|
+
if (existed) {
|
|
398
|
+
sessionManager.remove(sessionId)
|
|
399
|
+
broadcastRevisionUpdate(sessionId)
|
|
400
|
+
}
|
|
401
|
+
return jsonResponse({ ok: true, existed })
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function handleSessions(): Response {
|
|
405
|
+
return jsonResponse(
|
|
406
|
+
sessionManager.list().map((s) => ({
|
|
407
|
+
id: s.id,
|
|
408
|
+
projectRoot: s.projectRoot,
|
|
409
|
+
currentRevision: s.currentRevision,
|
|
410
|
+
updatedAt: s.updatedAt,
|
|
411
|
+
})),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function handleBuildEvent(req: Request): Promise<Response> {
|
|
416
|
+
try {
|
|
417
|
+
const body: unknown = await req.json()
|
|
418
|
+
const event = parseBuildEvent(body)
|
|
419
|
+
if (event === null) return jsonResponse({ error: 'Invalid or missing status' }, 400)
|
|
420
|
+
const ev = buildStream.append(event)
|
|
421
|
+
return jsonResponse({ ok: true, event: ev })
|
|
422
|
+
} catch (error: unknown) {
|
|
423
|
+
return jsonResponse({ error: errorMessage(error) }, 500)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function handleBuildStream(): Response {
|
|
428
|
+
const encoder = new TextEncoder()
|
|
429
|
+
let unsubscribe: (() => void) | null = null
|
|
430
|
+
const stream = new ReadableStream({
|
|
431
|
+
start(controller) {
|
|
432
|
+
const send = (ev: BuildEvent) => {
|
|
433
|
+
try {
|
|
434
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`))
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const ev of buildStream.history()) send(ev)
|
|
439
|
+
controller.enqueue(encoder.encode(`: connected\n\n`))
|
|
440
|
+
unsubscribe = buildStream.subscribe(send)
|
|
441
|
+
},
|
|
442
|
+
cancel() {
|
|
443
|
+
unsubscribe?.()
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
return new Response(stream, {
|
|
447
|
+
headers: {
|
|
448
|
+
'Content-Type': 'text/event-stream',
|
|
449
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
450
|
+
Connection: 'keep-alive',
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return [
|
|
456
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/health' }), handler: handleHealth },
|
|
457
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/phase' }), handler: handlePhase },
|
|
458
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/plan' }), handler: handlePlanPost },
|
|
459
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/canvas/:filename' }), handler: handleCanvasJs },
|
|
460
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/meta' }), handler: handleMeta },
|
|
461
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/artifact' }), handler: handleArtifact },
|
|
462
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/skeletons' }), handler: handleSkeletons },
|
|
463
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/feedback' }), handler: handleFeedbackGet },
|
|
464
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/feedback/consume' }), handler: handleFeedbackConsume },
|
|
465
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/reset' }), handler: handleSessionReset },
|
|
466
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/responses' }), handler: handleResponsesGet },
|
|
467
|
+
{ method: 'PUT', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/responses' }), handler: handleResponsesPut },
|
|
468
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/sessions' }), handler: handleSessions },
|
|
469
|
+
{ method: 'POST', pattern: new URLPattern({ pathname: '/api/build/event' }), handler: handleBuildEvent },
|
|
470
|
+
{ method: 'GET', pattern: new URLPattern({ pathname: '/api/build/stream' }), handler: handleBuildStream },
|
|
471
|
+
]
|
|
472
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createWebSocketManager, SessionManager, unwatchAll, type WSData } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { dispatch } from '@fabrika/daemon-kit'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
import { BuildStream } from './build-stream'
|
|
5
|
+
import { corsHeaders, createHandlers } from './handlers'
|
|
6
|
+
import { SESSIONS_DIR } from './paths'
|
|
7
|
+
import { createStaticHandlers } from './static'
|
|
8
|
+
|
|
9
|
+
export type { BuildEvent } from './build-stream'
|
|
10
|
+
export { derivePhaseState, PHASES } from './phases'
|
|
11
|
+
export type { Phase, PhaseId, PhaseState } from './phases'
|
|
12
|
+
|
|
13
|
+
export interface GateRecord {
|
|
14
|
+
name: string
|
|
15
|
+
revision: number
|
|
16
|
+
hasFeedback: boolean
|
|
17
|
+
consumed: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
|
|
21
|
+
const VERSION = '0.0.0'
|
|
22
|
+
|
|
23
|
+
export interface StartDaemonOptions {
|
|
24
|
+
port?: number
|
|
25
|
+
sessionsDir?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunningDaemon {
|
|
29
|
+
server: import('bun').Server<WSData>
|
|
30
|
+
port: number
|
|
31
|
+
sessionManager: SessionManager
|
|
32
|
+
stop(): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
|
|
36
|
+
const port = opts.port ?? DEFAULT_PORT
|
|
37
|
+
|
|
38
|
+
const sessionsDir = resolve(opts.sessionsDir ?? SESSIONS_DIR)
|
|
39
|
+
const sessionManager = new SessionManager(sessionsDir)
|
|
40
|
+
sessionManager.cleanupStale()
|
|
41
|
+
const wsManager = createWebSocketManager(sessionManager, { feedbackConsumption: 'external' })
|
|
42
|
+
const buildStream = new BuildStream()
|
|
43
|
+
|
|
44
|
+
const portRef = { value: port }
|
|
45
|
+
|
|
46
|
+
const handlerCtx = {
|
|
47
|
+
sessionManager,
|
|
48
|
+
broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
|
|
49
|
+
broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
|
|
50
|
+
buildStream,
|
|
51
|
+
sessionsDir,
|
|
52
|
+
get port() {
|
|
53
|
+
return portRef.value
|
|
54
|
+
},
|
|
55
|
+
version: VERSION,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const routes = [...createHandlers(handlerCtx), ...createStaticHandlers()]
|
|
59
|
+
|
|
60
|
+
const server = Bun.serve<WSData>({
|
|
61
|
+
port,
|
|
62
|
+
hostname: 'localhost',
|
|
63
|
+
fetch(req, server) {
|
|
64
|
+
const url = new URL(req.url)
|
|
65
|
+
|
|
66
|
+
if (req.method === 'OPTIONS') {
|
|
67
|
+
return new Response(null, { status: 204, headers: corsHeaders(portRef.value) })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wsBrowser = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
|
|
71
|
+
if (wsBrowser) {
|
|
72
|
+
const ok = server.upgrade(req, { data: { type: 'browser', sessionId: wsBrowser[1]! } })
|
|
73
|
+
if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const wsWait = url.pathname.match(/^\/ws\/wait\/([^/]+)$/)
|
|
78
|
+
if (wsWait) {
|
|
79
|
+
const ok = server.upgrade(req, { data: { type: 'wait', sessionId: wsWait[1]! } })
|
|
80
|
+
if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = dispatch(routes, req, url)
|
|
85
|
+
if (response) return response
|
|
86
|
+
|
|
87
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
88
|
+
const sessions = sessionManager.list()
|
|
89
|
+
if (sessions.length > 0) {
|
|
90
|
+
const latest = sessions.sort(
|
|
91
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
|
92
|
+
)[0]!
|
|
93
|
+
return Response.redirect(`/s/${latest.id}`, 302)
|
|
94
|
+
}
|
|
95
|
+
return new Response(JSON.stringify({ message: 'No active sessions', sessions: [] }), {
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
101
|
+
status: 404,
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
websocket: wsManager.handlers,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const boundPort = server.port ?? port
|
|
109
|
+
portRef.value = boundPort
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
server,
|
|
113
|
+
port: boundPort,
|
|
114
|
+
sessionManager,
|
|
115
|
+
stop() {
|
|
116
|
+
try {
|
|
117
|
+
unwatchAll()
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
server.stop(true)
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createCanvasPaths } from '@fabrika/canvas-kernel/server'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { homedir, tmpdir } from 'os'
|
|
4
|
+
import { join, resolve } from 'path'
|
|
5
|
+
|
|
6
|
+
export const NEUTRAL_DATA_DIR = join(homedir(), '.fabrika')
|
|
7
|
+
export const LEGACY_DATA_DIR = join(homedir(), '.claude', 'fabrika')
|
|
8
|
+
|
|
9
|
+
export function resolveDataDir(): string {
|
|
10
|
+
const explicit = process.env['FABRIKA_DATA_DIR']
|
|
11
|
+
if (explicit) return resolve(explicit)
|
|
12
|
+
if (existsSync(NEUTRAL_DATA_DIR)) return NEUTRAL_DATA_DIR
|
|
13
|
+
if (existsSync(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR
|
|
14
|
+
return NEUTRAL_DATA_DIR
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const paths = createCanvasPaths({
|
|
18
|
+
appName: 'fabrika',
|
|
19
|
+
dataDir: resolveDataDir(),
|
|
20
|
+
tempDir: join(tmpdir(), 'fabrika'),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const DATA_DIR = paths.dataDir
|
|
24
|
+
export const SESSIONS_DIR = paths.sessionsDir
|
|
25
|
+
export const TEMP_DIR = paths.tempDir
|
|
26
|
+
export const PID_FILE = paths.pidFile
|
|
27
|
+
export const COMPILE_TEMP_DIR = paths.compileTempDir
|
|
28
|
+
export const UPLOADS_DIR = paths.uploadsDir
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
|