golem-kit 0.1.1 → 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/CHANGELOG.md +31 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +19 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { Cron } from 'croner'
|
|
2
|
+
import {
|
|
3
|
+
anonymous, defineOperation, ForbiddenError, InvalidError, NotFoundError, RecordRefusedError, z,
|
|
4
|
+
type JobContext, type Operation, type Principal, type RecordStore, type Row,
|
|
5
|
+
} from '../operations.ts'
|
|
6
|
+
|
|
7
|
+
/** Reserved collections: the records operations refuse `_` names, so run state never leaks through them. */
|
|
8
|
+
export const SCHEDULES = '_job_schedules'
|
|
9
|
+
export const RUNS = '_job_runs'
|
|
10
|
+
// Finished runs are kept; there is no retention limit yet.
|
|
11
|
+
/** The only name job state puts on the change stream; readers re-list through `jobs.runs`. */
|
|
12
|
+
export const JOBS_CHANGE = '_jobs'
|
|
13
|
+
|
|
14
|
+
/** An app-owned job: which registered operation it runs. Callers pick the job and its input, never the operation or who it acts for. */
|
|
15
|
+
export type JobDefinition = {
|
|
16
|
+
name: string
|
|
17
|
+
description: string
|
|
18
|
+
/** A registered operation; each run goes through `invoke` and `authorize` like any other call. */
|
|
19
|
+
operation: string
|
|
20
|
+
/** Signed-out guests may start and schedule it; its runs act as `anonymous`. Apps without accounts are always anonymous. */
|
|
21
|
+
anonymous?: boolean
|
|
22
|
+
/** A scheduled time that passed while the server was down: `skip` (default) waits for the next one, `once` runs one catch-up at start. */
|
|
23
|
+
missed?: 'skip' | 'once'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Deps = {
|
|
27
|
+
store: RecordStore
|
|
28
|
+
definitions(): JobDefinition[]
|
|
29
|
+
hasAccounts: boolean
|
|
30
|
+
resolveAccount(id: string): Promise<Principal>
|
|
31
|
+
invoke(name: string, input: unknown, principal: Principal, job: JobContext): Promise<unknown>
|
|
32
|
+
emit(): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const maxDelay = 2 ** 31 - 1
|
|
36
|
+
const now = () => new Date().toISOString()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Durable server-side runs of app operations, started by hand or by an interval/cron schedule.
|
|
40
|
+
* A run outlives the request that started it; one found still running at start was cut off by a
|
|
41
|
+
* stop, so it becomes `interrupted` and waits for someone to retry or dismiss it — never replayed.
|
|
42
|
+
*/
|
|
43
|
+
export function createJobs(deps: Deps) {
|
|
44
|
+
const { store } = deps
|
|
45
|
+
const definition = (name: string) => deps.definitions().find((one) => one.name === name)
|
|
46
|
+
const active = new Map<string, AbortController>()
|
|
47
|
+
const timers = new Map<string, NodeJS.Timeout>()
|
|
48
|
+
let closed = false
|
|
49
|
+
// Claims (a slot firing, a retry, an unschedule) read and write run state in one step, one at a time,
|
|
50
|
+
// so two callers never both see a run as unclaimed. Operations themselves run outside this queue.
|
|
51
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
52
|
+
const serial = <T>(step: () => Promise<T>): Promise<T> => {
|
|
53
|
+
const result = queue.then(step)
|
|
54
|
+
queue = result.catch(() => {})
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function all(collection: string, filter?: Record<string, string | null>): Promise<Row[]> {
|
|
59
|
+
const rows: Row[] = []
|
|
60
|
+
let cursor: string | null = null
|
|
61
|
+
do {
|
|
62
|
+
const page: Awaited<ReturnType<RecordStore['list']>> = await store.list(collection, { filter, cursor, limit: 500 })
|
|
63
|
+
rows.push(...page.rows)
|
|
64
|
+
cursor = page.nextCursor
|
|
65
|
+
} while (cursor)
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
const write = async <T>(result: Promise<T>) => { const value = await result; deps.emit(); return value }
|
|
69
|
+
|
|
70
|
+
function nextAfter(schedule: Record<string, unknown>, from: Date): string {
|
|
71
|
+
if (typeof schedule.every === 'number') return new Date(from.getTime() + (schedule.every as number) * 1000).toISOString()
|
|
72
|
+
const next = new Cron(String(schedule.cron), { timezone: String(schedule.timezone), paused: true }).nextRun(from)
|
|
73
|
+
if (!next) throw new InvalidError('This cron expression never runs again')
|
|
74
|
+
return next.toISOString()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function job(name: string): JobDefinition {
|
|
78
|
+
const found = definition(name)
|
|
79
|
+
if (!found) throw new NotFoundError(`Unknown job: ${name}`)
|
|
80
|
+
return found
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The job as it was when the run or schedule was made: a removed job, or one now pointing at another operation, never runs. */
|
|
84
|
+
function unchanged(row: Row): JobDefinition {
|
|
85
|
+
const found = job(String(row.job))
|
|
86
|
+
if (found.operation !== row.operation) throw new RecordRefusedError(`Job ${found.name} now runs ${found.operation}, not ${String(row.operation)}; schedule it again`)
|
|
87
|
+
return found
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Runs are owned by the account that started them; nobody reads or acts on someone else's. */
|
|
91
|
+
function owned(row: Row | null, principal: Principal): Row {
|
|
92
|
+
const owner = principal.kind === 'user' ? principal.id : null
|
|
93
|
+
if (!row || (row.accountId ?? null) !== owner) throw new NotFoundError('No such job run or schedule')
|
|
94
|
+
return row
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function actor(definition: JobDefinition, principal: Principal): string | null {
|
|
98
|
+
if (principal.kind === 'user') {
|
|
99
|
+
if (!deps.hasAccounts) throw new ForbiddenError('Jobs that act for a person need local accounts')
|
|
100
|
+
return principal.id
|
|
101
|
+
}
|
|
102
|
+
if (deps.hasAccounts && !definition.anonymous) throw new ForbiddenError(`Sign in to run ${definition.name}`)
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function execute(run: Row): Promise<void> {
|
|
107
|
+
const controller = new AbortController()
|
|
108
|
+
active.set(run.id, controller)
|
|
109
|
+
const context: JobContext = {
|
|
110
|
+
runId: run.id,
|
|
111
|
+
key: String(run.key),
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
progress: async (progress) => { await write(store.update(RUNS, run.id, { progress })) },
|
|
114
|
+
}
|
|
115
|
+
let patch: Record<string, unknown>
|
|
116
|
+
try {
|
|
117
|
+
const definition = unchanged(run)
|
|
118
|
+
// Current roles and groups on every run: a removed account or a lost role stops its jobs.
|
|
119
|
+
const principal = run.accountId ? await deps.resolveAccount(String(run.accountId)) : anonymous
|
|
120
|
+
const result = await deps.invoke(definition.operation, run.input, principal, context)
|
|
121
|
+
patch = { status: 'succeeded', result: result ?? null }
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
124
|
+
patch = { status: controller.signal.aborted ? 'cancelled' : 'failed', error: failure }
|
|
125
|
+
} finally {
|
|
126
|
+
active.delete(run.id)
|
|
127
|
+
}
|
|
128
|
+
if (!closed) await write(store.update(RUNS, run.id, { ...patch, finishedAt: now() })).catch((error) => console.error(error))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function begin(fields: { job: string; operation: string; input: unknown; accountId: string | null; scheduleId: string | null; key?: string; retryOf?: string }): Promise<Row> {
|
|
132
|
+
const id = crypto.randomUUID()
|
|
133
|
+
const run = await write(store.create(RUNS, { id, ...fields, key: fields.key ?? id, status: 'running', progress: null, startedAt: now(), cancelRequested: false }))
|
|
134
|
+
void execute(run)
|
|
135
|
+
return run
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tick = (scheduleId: string) => serial(async () => {
|
|
139
|
+
timers.delete(scheduleId)
|
|
140
|
+
if (closed) return
|
|
141
|
+
const schedule = await store.get(SCHEDULES, scheduleId)
|
|
142
|
+
if (!schedule) return
|
|
143
|
+
const slot = String(schedule.nextRunAt)
|
|
144
|
+
if (Date.parse(slot) > Date.now()) return arm(schedule)
|
|
145
|
+
await fire(schedule, slot, new Date())
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One scheduled slot. Overlap is skipped: while a run of this schedule is still running (a
|
|
150
|
+
* cancelled one included, until its operation returns), or was interrupted and nobody has retried
|
|
151
|
+
* or dismissed it, the slot is recorded as skipped instead. So is a slot whose job was removed or changed.
|
|
152
|
+
*/
|
|
153
|
+
async function fire(schedule: Row, slot: string, from: Date): Promise<void> {
|
|
154
|
+
const pending = (await all(RUNS, { scheduleId: schedule.id })).some((run) => run.status === 'running' || (run.status === 'interrupted' && !run.resolution))
|
|
155
|
+
let problem: string | null = null
|
|
156
|
+
try { unchanged(schedule) } catch (error) { problem = (error as Error).message }
|
|
157
|
+
const skip = pending || problem !== null
|
|
158
|
+
const next = await store.update(SCHEDULES, schedule.id, { nextRunAt: nextAfter(schedule, from), error: problem, ...(skip ? { lastSkippedAt: slot } : { lastRunAt: slot }) })
|
|
159
|
+
deps.emit()
|
|
160
|
+
if (!skip) await begin({ job: String(schedule.job), operation: String(schedule.operation), input: schedule.input, accountId: (schedule.accountId as string | null) ?? null, scheduleId: schedule.id, key: `${schedule.id}-${Date.parse(slot)}` })
|
|
161
|
+
arm(next)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function arm(schedule: Row): void {
|
|
165
|
+
clearTimeout(timers.get(schedule.id))
|
|
166
|
+
if (closed) return
|
|
167
|
+
const delay = Math.min(Math.max(Date.parse(String(schedule.nextRunAt)) - Date.now(), 0), maxDelay)
|
|
168
|
+
const timer = setTimeout(() => void tick(schedule.id).catch((error) => console.error(error)), delay)
|
|
169
|
+
timers.set(schedule.id, timer.unref())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function start(): Promise<void> {
|
|
173
|
+
for (const run of await all(RUNS, { status: 'running' })) {
|
|
174
|
+
await store.update(RUNS, run.id, { status: 'interrupted', finishedAt: now(), error: 'The server stopped while this run was in progress. Its effects may be partial; retry or dismiss it.' })
|
|
175
|
+
}
|
|
176
|
+
for (const schedule of await all(SCHEDULES)) {
|
|
177
|
+
const slot = String(schedule.nextRunAt)
|
|
178
|
+
if (Date.parse(slot) > Date.now()) { arm(schedule); continue }
|
|
179
|
+
if (definition(String(schedule.job))?.missed === 'once') { await serial(() => fire(schedule, slot, new Date())); continue }
|
|
180
|
+
arm(await store.update(SCHEDULES, schedule.id, { nextRunAt: nextAfter(schedule, new Date()), lastMissedAt: slot }))
|
|
181
|
+
}
|
|
182
|
+
deps.emit()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ready = start().catch((error) => { console.error('Jobs failed to start', error) })
|
|
186
|
+
|
|
187
|
+
const runRecord = (input: { id: string }) => ({ collection: RUNS, id: input.id })
|
|
188
|
+
const scheduleRecord = (input: { id: string }) => ({ collection: SCHEDULES, id: input.id })
|
|
189
|
+
const row = z.looseObject({ id: z.string(), version: z.number() })
|
|
190
|
+
const id = z.string().min(1)
|
|
191
|
+
const input = z.unknown().optional()
|
|
192
|
+
|
|
193
|
+
// Management goes through `invoke`, so the app's `authorize` sees each call (and the run or schedule as `record`).
|
|
194
|
+
const operations: Operation[] = [
|
|
195
|
+
defineOperation({
|
|
196
|
+
name: 'jobs.list', description: 'List the app jobs and your schedules.',
|
|
197
|
+
input: z.object({}), output: z.object({ jobs: z.array(z.object({ name: z.string(), description: z.string() })), schedules: z.array(row) }),
|
|
198
|
+
async run(_, { principal, permits }) {
|
|
199
|
+
await ready
|
|
200
|
+
const owner = principal.kind === 'user' ? principal.id : null
|
|
201
|
+
const mine = (await all(SCHEDULES, { accountId: owner }))
|
|
202
|
+
const visible = await Promise.all(mine.map(permits))
|
|
203
|
+
return { jobs: deps.definitions().map(({ name, description }) => ({ name, description })), schedules: mine.filter((_, index) => visible[index]) }
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
defineOperation({
|
|
207
|
+
name: 'jobs.start', description: 'Start one run of a job now. It keeps running if the browser goes away.',
|
|
208
|
+
input: z.object({ job: z.string(), input }), output: row,
|
|
209
|
+
async run(request, { principal }) {
|
|
210
|
+
await ready
|
|
211
|
+
const definition = job(request.job)
|
|
212
|
+
return begin({ job: definition.name, operation: definition.operation, input: request.input ?? {}, accountId: actor(definition, principal), scheduleId: null })
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
defineOperation({
|
|
216
|
+
name: 'jobs.schedule', description: 'Run a job every N seconds, or on a cron expression in an explicit IANA timezone.',
|
|
217
|
+
input: z.union([
|
|
218
|
+
z.object({ job: z.string(), input, every: z.number().int().min(10) }),
|
|
219
|
+
z.object({ job: z.string(), input, cron: z.string().max(120), timezone: z.string().max(64) }),
|
|
220
|
+
]),
|
|
221
|
+
output: row,
|
|
222
|
+
async run(request, { principal }) {
|
|
223
|
+
await ready
|
|
224
|
+
const definition = job(request.job)
|
|
225
|
+
const timing = 'every' in request ? { every: request.every } : { cron: request.cron, timezone: request.timezone }
|
|
226
|
+
let nextRunAt: string
|
|
227
|
+
try { nextRunAt = nextAfter(timing, new Date()) } catch (error) { throw new InvalidError(`Invalid schedule: ${(error as Error).message}`) }
|
|
228
|
+
const schedule = await write(store.create(SCHEDULES, { job: definition.name, operation: definition.operation, input: request.input ?? {}, accountId: actor(definition, principal), ...timing, nextRunAt }))
|
|
229
|
+
arm(schedule)
|
|
230
|
+
return schedule
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
defineOperation({
|
|
234
|
+
name: 'jobs.unschedule', description: 'Stop a schedule. Runs it already started are unaffected.',
|
|
235
|
+
input: z.object({ id }), output: z.null(), record: scheduleRecord,
|
|
236
|
+
run: (request, { principal }) => serial(async () => {
|
|
237
|
+
owned(await store.get(SCHEDULES, request.id), principal)
|
|
238
|
+
clearTimeout(timers.get(request.id))
|
|
239
|
+
timers.delete(request.id)
|
|
240
|
+
await write(store.remove(SCHEDULES, request.id))
|
|
241
|
+
return null
|
|
242
|
+
}),
|
|
243
|
+
}),
|
|
244
|
+
defineOperation({
|
|
245
|
+
name: 'jobs.runs', description: 'Your recent job runs, newest first, with status, progress, result and error.',
|
|
246
|
+
input: z.object({ job: z.string().optional(), scheduleId: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }),
|
|
247
|
+
output: z.array(row),
|
|
248
|
+
async run(request, { principal, permits }) {
|
|
249
|
+
await ready
|
|
250
|
+
const filter: Record<string, string | null> = { accountId: principal.kind === 'user' ? principal.id : null }
|
|
251
|
+
if (request.job) filter.job = request.job
|
|
252
|
+
if (request.scheduleId) filter.scheduleId = request.scheduleId
|
|
253
|
+
const page = await store.list(RUNS, { filter, sort: { field: 'startedAt', direction: 'desc' }, limit: request.limit ?? 50 })
|
|
254
|
+
const visible = await Promise.all(page.rows.map(permits))
|
|
255
|
+
return page.rows.filter((_, index) => visible[index])
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
defineOperation({
|
|
259
|
+
name: 'jobs.cancel', description: 'Ask a running job to stop. The operation stops at its next check; work it already did stays done.',
|
|
260
|
+
input: z.object({ id }), output: row, record: runRecord,
|
|
261
|
+
async run(request, { principal }) {
|
|
262
|
+
const run = owned(await store.get(RUNS, request.id), principal)
|
|
263
|
+
const controller = active.get(run.id)
|
|
264
|
+
if (run.status !== 'running' || !controller) throw new RecordRefusedError('Only a running job can be cancelled')
|
|
265
|
+
const updated = await write(store.update(RUNS, run.id, { cancelRequested: true }))
|
|
266
|
+
controller.abort(new Error('Cancelled'))
|
|
267
|
+
return updated
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
defineOperation({
|
|
271
|
+
name: 'jobs.resolve', description: 'Settle an interrupted run: retry starts a new run with the same input and idempotency key; dismiss leaves it as is.',
|
|
272
|
+
input: z.object({ id, action: z.enum(['retry', 'dismiss']) }), output: row, record: runRecord,
|
|
273
|
+
run: (request, { principal }) => serial(async () => {
|
|
274
|
+
const run = owned(await store.get(RUNS, request.id), principal)
|
|
275
|
+
if (run.status !== 'interrupted' || run.resolution) throw new RecordRefusedError('Only an unsettled interrupted run can be retried or dismissed')
|
|
276
|
+
if (request.action === 'dismiss') return write(store.update(RUNS, run.id, { resolution: 'dismissed' }))
|
|
277
|
+
// Re-checks who may start the job now: the retry acts as the same account with its current roles.
|
|
278
|
+
actor(unchanged(run), principal)
|
|
279
|
+
// The retry exists before the old run is marked settled: a stop in between leaves both visible, never neither.
|
|
280
|
+
const retry = await begin({ job: String(run.job), operation: String(run.operation), input: run.input, accountId: (run.accountId as string | null) ?? null, scheduleId: (run.scheduleId as string | null) ?? null, key: String(run.key), retryOf: run.id })
|
|
281
|
+
await write(store.update(RUNS, run.id, { resolution: 'retried', retryId: retry.id }))
|
|
282
|
+
return retry
|
|
283
|
+
}),
|
|
284
|
+
}),
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
operations,
|
|
289
|
+
/** Resolves once interrupted runs are marked and schedules armed. */
|
|
290
|
+
ready,
|
|
291
|
+
/**
|
|
292
|
+
* Stops the timers and aborts every running operation's signal. Nothing more is recorded: those
|
|
293
|
+
* runs stay `running` and become `interrupted` at the next start, whatever their operations did meanwhile.
|
|
294
|
+
*/
|
|
295
|
+
close() {
|
|
296
|
+
closed = true
|
|
297
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
298
|
+
timers.clear()
|
|
299
|
+
for (const controller of active.values()) controller.abort(new Error('The server is stopping'))
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, open, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { NotFoundError, RecordRefusedError, validCollection, validId, type RecordStore, type Row } from '../operations.ts'
|
|
5
|
+
import { json, nextVersion, page } from './rules.ts'
|
|
6
|
+
|
|
7
|
+
type Entry = { put: Row } | { remove: string }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One append-only JSONL log per collection, replayed into memory on first use and fsynced per write.
|
|
11
|
+
* Single-process only and one write at a time; use the SQLite store for anything busier.
|
|
12
|
+
*/
|
|
13
|
+
export async function jsonlStore(directory: string): Promise<RecordStore & { native: string }> {
|
|
14
|
+
await mkdir(directory, { recursive: true })
|
|
15
|
+
const collections = new Map<string, Map<string, Row>>()
|
|
16
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
17
|
+
const serial = <T>(work: () => Promise<T>): Promise<T> => {
|
|
18
|
+
const run = queue.then(work)
|
|
19
|
+
queue = run.catch(() => {})
|
|
20
|
+
return run
|
|
21
|
+
}
|
|
22
|
+
const file = (collection: string) => join(directory, `${validCollection(collection)}.jsonl`)
|
|
23
|
+
|
|
24
|
+
async function load(collection: string): Promise<Map<string, Row>> {
|
|
25
|
+
const loaded = collections.get(collection)
|
|
26
|
+
if (loaded) return loaded
|
|
27
|
+
const rows = new Map<string, Row>()
|
|
28
|
+
let text = ''
|
|
29
|
+
try { text = await readFile(file(collection), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
30
|
+
// A crash can leave one torn line at the end; everything before the last newline was acknowledged.
|
|
31
|
+
const complete = text.slice(0, text.lastIndexOf('\n') + 1)
|
|
32
|
+
const lines = complete.split('\n').filter(Boolean)
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const entry = JSON.parse(line) as Entry
|
|
35
|
+
if ('put' in entry) rows.set(entry.put.id, entry.put)
|
|
36
|
+
else rows.delete(entry.remove)
|
|
37
|
+
}
|
|
38
|
+
if (complete.length !== text.length || lines.length > rows.size * 2 + 100) {
|
|
39
|
+
const temporary = `${file(collection)}.${process.pid}.tmp`
|
|
40
|
+
await writeFile(temporary, [...rows.values()].map((row) => `${JSON.stringify({ put: row })}\n`).join(''), { flush: true })
|
|
41
|
+
await rename(temporary, file(collection))
|
|
42
|
+
}
|
|
43
|
+
collections.set(collection, rows)
|
|
44
|
+
return rows
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function append(collection: string, entry: Entry): Promise<void> {
|
|
48
|
+
const handle = await open(file(collection), 'a')
|
|
49
|
+
try {
|
|
50
|
+
await handle.appendFile(`${JSON.stringify(entry)}\n`)
|
|
51
|
+
await handle.datasync()
|
|
52
|
+
} finally {
|
|
53
|
+
await handle.close()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
native: directory,
|
|
59
|
+
async list(collection, query) { return structuredClone(page([...(await serial(() => load(collection))).values()], query)) },
|
|
60
|
+
async get(collection, id) { return structuredClone((await serial(() => load(collection))).get(validId(id)) ?? null) },
|
|
61
|
+
create: (collection, data) => serial(async () => {
|
|
62
|
+
const rows = await load(collection)
|
|
63
|
+
const id = data.id === undefined ? randomUUID() : validId(String(data.id))
|
|
64
|
+
if (rows.has(id)) throw new RecordRefusedError(`A record with id ${id} already exists`, [{ field: 'id', message: 'This id is already taken.' }])
|
|
65
|
+
const now = new Date().toISOString()
|
|
66
|
+
const row: Row = { ...json(data), id, version: 1, createdAt: now, updatedAt: now }
|
|
67
|
+
await append(collection, { put: row })
|
|
68
|
+
rows.set(id, row)
|
|
69
|
+
return structuredClone(row)
|
|
70
|
+
}),
|
|
71
|
+
update: (collection, id, patch, options) => serial(async () => {
|
|
72
|
+
const rows = await load(collection)
|
|
73
|
+
const current = rows.get(validId(id))
|
|
74
|
+
const row = nextVersion(collection, id, current, patch, options)
|
|
75
|
+
await append(collection, { put: row })
|
|
76
|
+
rows.set(id, row)
|
|
77
|
+
return structuredClone(row)
|
|
78
|
+
}),
|
|
79
|
+
remove: (collection, id) => serial(async () => {
|
|
80
|
+
const rows = await load(collection)
|
|
81
|
+
if (!rows.has(validId(id))) throw new NotFoundError(`No ${collection} record ${id}`)
|
|
82
|
+
await append(collection, { remove: id })
|
|
83
|
+
rows.delete(id)
|
|
84
|
+
}),
|
|
85
|
+
close: () => serial(async () => { collections.clear() }),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
defineOperation, ForbiddenError, InvalidError, NotFoundError, validCollection, VersionConflictError, z,
|
|
7
|
+
type Operation, type RecordStore, type Row,
|
|
8
|
+
} from '../operations.ts'
|
|
9
|
+
|
|
10
|
+
/** Internal collection holding one revision row per file: the numeric version Editor saves against and the last sha256 seen. */
|
|
11
|
+
export const KNOWLEDGE_COLLECTION = '_knowledge'
|
|
12
|
+
|
|
13
|
+
/** Knowledge roots an app's server module opts into: root name → directory relative to the app root. */
|
|
14
|
+
export type KnowledgeRoots = Record<string, string>
|
|
15
|
+
|
|
16
|
+
/** One markdown file as operations return it. `id` is the path, so it plugs into golem-ui's Records contract. */
|
|
17
|
+
export type KnowledgeFile = Row & { root: string; path: string; body: string; sha256: string; type: string | null; title: string }
|
|
18
|
+
|
|
19
|
+
const maxBytes = 2_000_000
|
|
20
|
+
|
|
21
|
+
// One queue per absolute file path, shared across server-module reloads.
|
|
22
|
+
// In-process only: a second server process on the same app is not covered.
|
|
23
|
+
const locks = new Map<string, Promise<unknown>>()
|
|
24
|
+
function serial<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
25
|
+
const next = (locks.get(key) ?? Promise.resolve()).then(work, work)
|
|
26
|
+
const settled = next.catch(() => {})
|
|
27
|
+
locks.set(key, settled)
|
|
28
|
+
void settled.then(() => { if (locks.get(key) === settled) locks.delete(key) })
|
|
29
|
+
return next
|
|
30
|
+
}
|
|
31
|
+
const segment = /^[^/\\\0.][^/\\\0]{0,127}$/
|
|
32
|
+
|
|
33
|
+
/** A relative `.md` path inside a root: no `..`, no hidden segments, no backslashes. */
|
|
34
|
+
export function validPath(value: string): string {
|
|
35
|
+
const parts = typeof value === 'string' ? value.split('/') : []
|
|
36
|
+
if (!parts.length || value.length > 512 || !value.endsWith('.md') || !parts.every((part) => segment.test(part))) {
|
|
37
|
+
throw new InvalidError(`Invalid knowledge path: ${JSON.stringify(value)}`)
|
|
38
|
+
}
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What `authorize` sees as `record` for every knowledge operation, whether or not the file exists yet. */
|
|
43
|
+
export const knowledgeEntry = (root: string, path: string): Row => ({ id: `${root}/${path}`, root, path, version: 0, createdAt: '', updatedAt: '' })
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Markdown files under trusted roots, versioned for Editor. Golem's own writes are serialized per
|
|
47
|
+
* file and compare the disk bytes against the version they were given right before the rename;
|
|
48
|
+
* an edit made on disk since the last read shows up as a new version. Editors that do not go through
|
|
49
|
+
* Golem are not locked out: one that writes in the instant between that check and the rename is overwritten.
|
|
50
|
+
*/
|
|
51
|
+
export function knowledgeOperations(appRoot: string, roots: KnowledgeRoots): Operation[] {
|
|
52
|
+
if (!roots || typeof roots !== 'object' || Array.isArray(roots)) throw new Error('knowledge must map root names to directories')
|
|
53
|
+
for (const [name, directory] of Object.entries(roots)) {
|
|
54
|
+
validCollection(name)
|
|
55
|
+
if (typeof directory !== 'string' || !directory || directory.startsWith('/') || directory.split(/[/\\]/).includes('..')) {
|
|
56
|
+
throw new Error(`knowledge root ${name} must be a directory inside the app`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Policy: no symlink anywhere between the app root and a file, so a path is also the file's one identity.
|
|
60
|
+
async function base(root: string): Promise<string> {
|
|
61
|
+
if (!Object.hasOwn(roots, root)) throw new NotFoundError(`No knowledge root ${root}`)
|
|
62
|
+
const app = await realpath(appRoot)
|
|
63
|
+
const top = join(app, roots[root])
|
|
64
|
+
if (!(await unlinked(app, top))) throw new NotFoundError(`Knowledge root ${root} has no directory`)
|
|
65
|
+
return top
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Absolute path of a file under the root; every folder on the way is a real directory, and the file is opened without following links. */
|
|
69
|
+
async function locate(root: string, path: string): Promise<string> {
|
|
70
|
+
const top = await base(root)
|
|
71
|
+
const file = join(top, validPath(path))
|
|
72
|
+
await unlinked(top, dirname(file))
|
|
73
|
+
return file
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readDisk(file: string): Promise<Buffer | null> {
|
|
77
|
+
let handle
|
|
78
|
+
try {
|
|
79
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
82
|
+
if (code === 'ENOENT') return null
|
|
83
|
+
if (code === 'ELOOP') throw new ForbiddenError('Knowledge files may not be symlinks')
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const stat = await handle.stat()
|
|
88
|
+
// A second hard link would be a second name for the same bytes, possibly outside the root.
|
|
89
|
+
if (!stat.isFile() || stat.nlink > 1) throw new ForbiddenError('Not a knowledge file')
|
|
90
|
+
const bytes = await handle.readFile()
|
|
91
|
+
if (bytes.byteLength > maxBytes) throw new InvalidError('Knowledge file is too large')
|
|
92
|
+
return bytes
|
|
93
|
+
} finally {
|
|
94
|
+
await handle.close()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The revision row for these bytes: a sha the store has not seen yet bumps the version. */
|
|
99
|
+
async function revision(records: RecordStore, root: string, path: string, bytes: Buffer): Promise<Row> {
|
|
100
|
+
const id = createHash('sha256').update(`${root}\0${path}`).digest('hex').slice(0, 40)
|
|
101
|
+
const sha256 = hash(bytes)
|
|
102
|
+
const row = await records.get(KNOWLEDGE_COLLECTION, id)
|
|
103
|
+
if (!row) return records.create(KNOWLEDGE_COLLECTION, { id, root, path, sha256 })
|
|
104
|
+
return row.sha256 === sha256 ? row : records.update(KNOWLEDGE_COLLECTION, id, { sha256 })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function read(records: RecordStore, root: string, path: string): Promise<KnowledgeFile> {
|
|
108
|
+
const file = await locate(root, path)
|
|
109
|
+
return serial(file, async () => {
|
|
110
|
+
const bytes = await readDisk(file)
|
|
111
|
+
if (!bytes) throw new NotFoundError(`No knowledge file ${root}/${path}`)
|
|
112
|
+
return toFile(root, path, bytes, await revision(records, root, path, bytes))
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function write(records: RecordStore, root: string, path: string, body: string, expectedVersion: number): Promise<KnowledgeFile> {
|
|
117
|
+
const file = await locate(root, path)
|
|
118
|
+
return serial(file, async () => {
|
|
119
|
+
const before = await readDisk(file)
|
|
120
|
+
const current = before && await revision(records, root, path, before)
|
|
121
|
+
const conflict = async () => new VersionConflictError(`${root}/${path} changed since it was read`, before && current ? toFile(root, path, before, current) : null)
|
|
122
|
+
if (current ? current.version !== expectedVersion : expectedVersion !== 0) throw await conflict()
|
|
123
|
+
const bytes = Buffer.from(body, 'utf8')
|
|
124
|
+
if (bytes.byteLength > maxBytes) throw new InvalidError('Knowledge file is too large')
|
|
125
|
+
if (!before) await mkdirInside(await base(root), dirname(file))
|
|
126
|
+
const temporary = join(dirname(file), `.${randomUUID()}.golem-write`)
|
|
127
|
+
await writeFile(temporary, bytes, { flush: true, flag: 'wx' })
|
|
128
|
+
try {
|
|
129
|
+
if (!before) {
|
|
130
|
+
// link() refuses an existing name: a file created meanwhile is a conflict, not overwritten.
|
|
131
|
+
await link(temporary, file).catch(async (error) => { throw error.code === 'EEXIST' ? await conflict() : error })
|
|
132
|
+
} else {
|
|
133
|
+
const latest = await readDisk(file)
|
|
134
|
+
if (!latest || hash(latest) !== hash(before)) throw new VersionConflictError(`${root}/${path} changed on disk while saving`, latest ? toFile(root, path, latest, await revision(records, root, path, latest)) : null)
|
|
135
|
+
await rename(temporary, file)
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
await unlink(temporary).catch(() => {})
|
|
139
|
+
}
|
|
140
|
+
return toFile(root, path, bytes, await revision(records, root, path, bytes))
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function* walk(top: string, folder: string): AsyncGenerator<string> {
|
|
145
|
+
const entries = await readdir(join(top, folder), { withFileTypes: true }).catch(() => [])
|
|
146
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
147
|
+
if (entry.name.startsWith('.')) continue
|
|
148
|
+
const path = folder ? `${folder}/${entry.name}` : entry.name
|
|
149
|
+
// Symlinked entries are skipped, so a link never pulls outside content into a listing.
|
|
150
|
+
if (entry.isDirectory()) yield* walk(top, path)
|
|
151
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) yield path
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const root = z.string().min(1)
|
|
156
|
+
const path = z.string().transform((value, context) => {
|
|
157
|
+
try { return validPath(value) } catch (error) { context.addIssue({ code: 'custom', message: (error as Error).message }); return z.NEVER }
|
|
158
|
+
})
|
|
159
|
+
const entry = (input: { root: string; path: string }) => ({ row: knowledgeEntry(input.root, input.path) })
|
|
160
|
+
const fileRow = z.looseObject({ id: z.string(), root: z.string(), path: z.string(), version: z.number(), body: z.string() })
|
|
161
|
+
const listed = z.looseObject({ id: z.string(), root: z.string(), path: z.string(), version: z.number(), title: z.string(), type: z.string().nullable() })
|
|
162
|
+
|
|
163
|
+
// Folder paths are validated like file paths, minus the extension.
|
|
164
|
+
const folder = z.string().refine((value) => value === '' || value.split('/').every((part) => segment.test(part)), 'must be a relative folder')
|
|
165
|
+
async function files(input: { root: string; folder?: string }, permits: (row: Row) => Promise<boolean>) {
|
|
166
|
+
const top = await base(input.root)
|
|
167
|
+
if (input.folder && !(await unlinked(top, join(top, input.folder)))) return []
|
|
168
|
+
const found: string[] = []
|
|
169
|
+
for await (const one of walk(top, input.folder ?? '')) {
|
|
170
|
+
if (await permits(knowledgeEntry(input.root, one))) found.push(one)
|
|
171
|
+
}
|
|
172
|
+
return found
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
defineOperation({
|
|
177
|
+
name: 'knowledge.list',
|
|
178
|
+
description: 'List markdown files in a knowledge root (optionally one folder), with their title, OKF type and version. Start here to find sources.',
|
|
179
|
+
input: z.object({ root, folder: folder.optional() }),
|
|
180
|
+
output: z.object({ rows: z.array(listed), nextCursor: z.null() }),
|
|
181
|
+
async run(input, { records, permits }) {
|
|
182
|
+
const rows = []
|
|
183
|
+
for (const one of await files(input, permits)) {
|
|
184
|
+
const file = await read(records, input.root, one).catch(() => null)
|
|
185
|
+
if (file) rows.push(summary(file))
|
|
186
|
+
}
|
|
187
|
+
return { rows, nextCursor: null }
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
defineOperation({
|
|
191
|
+
name: 'knowledge.search',
|
|
192
|
+
description: 'Find lines containing some text across a knowledge root. Returns path, 1-based line number and that line, so an answer can cite its source.',
|
|
193
|
+
input: z.object({ root, text: z.string().min(2).max(200), limit: z.number().int().min(1).max(100).optional() }),
|
|
194
|
+
output: z.array(z.object({ path: z.string(), line: z.number(), text: z.string() })),
|
|
195
|
+
async run(input, { records, permits }) {
|
|
196
|
+
const needle = input.text.toLowerCase()
|
|
197
|
+
const hits: Array<{ path: string; line: number; text: string }> = []
|
|
198
|
+
for (const one of await files(input, permits)) {
|
|
199
|
+
const file = await read(records, input.root, one).catch(() => null)
|
|
200
|
+
file?.body.split('\n').forEach((text, index) => { if (text.toLowerCase().includes(needle)) hits.push({ path: one, line: index + 1, text: text.slice(0, 400) }) })
|
|
201
|
+
if (hits.length >= (input.limit ?? 30)) break
|
|
202
|
+
}
|
|
203
|
+
return hits.slice(0, input.limit ?? 30)
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
defineOperation({
|
|
207
|
+
name: 'knowledge.read',
|
|
208
|
+
description: 'Read one markdown file: its whole text (frontmatter included) as `body`, and the `version` a write must send back.',
|
|
209
|
+
input: z.object({ root, path }), output: fileRow, record: entry,
|
|
210
|
+
run: (input, { records }) => read(records, input.root, input.path),
|
|
211
|
+
}),
|
|
212
|
+
defineOperation({
|
|
213
|
+
name: 'knowledge.write',
|
|
214
|
+
description: 'Replace one markdown file with `body`. Send the `version` you read (0 to create a new file); a file changed since then is refused with the current text.',
|
|
215
|
+
input: z.object({ root, path, body: z.string(), expectedVersion: z.number().int().min(0) }), output: fileRow, record: entry,
|
|
216
|
+
run: (input, { records }) => write(records, input.root, input.path, input.body, input.expectedVersion),
|
|
217
|
+
}),
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True when every step from `top` down to `target` exists as a real directory; a symlink on the way is refused. */
|
|
222
|
+
async function unlinked(top: string, target: string): Promise<boolean> {
|
|
223
|
+
let at = top
|
|
224
|
+
for (const part of relative(top, target).split(sep).filter(Boolean)) {
|
|
225
|
+
at = join(at, part)
|
|
226
|
+
const stat = await lstat(at).catch(() => null)
|
|
227
|
+
if (!stat) return false
|
|
228
|
+
if (!stat.isDirectory()) throw new ForbiddenError('Path leaves the knowledge root')
|
|
229
|
+
}
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function mkdirInside(top: string, directory: string): Promise<void> {
|
|
234
|
+
const parts = relative(top, directory).split(sep).filter(Boolean)
|
|
235
|
+
let at = top
|
|
236
|
+
for (const part of parts) {
|
|
237
|
+
at = join(at, part)
|
|
238
|
+
const stat = await lstat(at).catch(() => null)
|
|
239
|
+
if (!stat) await mkdir(at).catch((error) => { if (error.code !== 'EEXIST') throw error })
|
|
240
|
+
else if (!stat.isDirectory()) throw new ForbiddenError('Path leaves the knowledge root')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
245
|
+
|
|
246
|
+
function toFile(root: string, path: string, bytes: Buffer, revision: Row): KnowledgeFile {
|
|
247
|
+
const body = bytes.toString('utf8')
|
|
248
|
+
const meta = frontmatter(body)
|
|
249
|
+
const title = meta.title || /^#\s+(.+)$/m.exec(body)?.[1]?.trim() || path.split('/').pop()!.replace(/\.md$/, '')
|
|
250
|
+
return { id: path, root, path, body, sha256: revision.sha256 as string, type: meta.type || null, title, version: revision.version, createdAt: revision.createdAt, updatedAt: revision.updatedAt }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const summary = ({ body: _body, ...rest }: KnowledgeFile) => rest
|
|
254
|
+
|
|
255
|
+
/** Top-level scalar keys of a YAML frontmatter block; enough for OKF's `type`, `title` and `description`. */
|
|
256
|
+
function frontmatter(text: string): Record<string, string> {
|
|
257
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text)?.[1]
|
|
258
|
+
const out: Record<string, string> = {}
|
|
259
|
+
for (const line of block?.split(/\r?\n/) ?? []) {
|
|
260
|
+
const match = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line)
|
|
261
|
+
if (match && match[2]) out[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2')
|
|
262
|
+
}
|
|
263
|
+
return out
|
|
264
|
+
}
|