@roj-ai/kompjutr-platform 0.0.0 → 0.2.10
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/README.md +114 -1
- package/dist/event-store.d.ts +48 -0
- package/dist/event-store.d.ts.map +1 -0
- package/dist/event-store.js +206 -0
- package/dist/event-store.js.map +1 -0
- package/dist/fs.d.ts +19 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +200 -0
- package/dist/fs.js.map +1 -0
- package/dist/git.d.ts +12 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +70 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/llm-call-log.d.ts +23 -0
- package/dist/llm-call-log.d.ts.map +1 -0
- package/dist/llm-call-log.js +135 -0
- package/dist/llm-call-log.js.map +1 -0
- package/dist/llm-call-payloads.d.ts +12 -0
- package/dist/llm-call-payloads.d.ts.map +1 -0
- package/dist/llm-call-payloads.js +77 -0
- package/dist/llm-call-payloads.js.map +1 -0
- package/dist/process.d.ts +14 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +25 -0
- package/dist/process.js.map +1 -0
- package/dist/session-log.d.ts +22 -0
- package/dist/session-log.d.ts.map +1 -0
- package/dist/session-log.js +65 -0
- package/dist/session-log.js.map +1 -0
- package/dist/shell.d.ts +11 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +36 -0
- package/dist/shell.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/storage.d.ts +29 -0
- package/dist/testing/storage.d.ts.map +1 -0
- package/dist/testing/storage.js +132 -0
- package/dist/testing/storage.js.map +1 -0
- package/package.json +44 -4
- package/src/event-store.ts +250 -0
- package/src/fs.ts +232 -0
- package/src/git.ts +74 -0
- package/src/index.ts +83 -0
- package/src/llm-call-log.ts +204 -0
- package/src/llm-call-payloads.ts +95 -0
- package/src/process.ts +28 -0
- package/src/session-log.ts +79 -0
- package/src/shell.ts +49 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/storage.ts +141 -0
package/src/process.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ProcessRunner` for a host with no process table.
|
|
3
|
+
*
|
|
4
|
+
* The port is required, because every host the SDK knew until now had one. A
|
|
5
|
+
* kompjutr workspace does not: it is a filesystem and a git runtime, and the
|
|
6
|
+
* command lines an agent writes are answered by `platform.shell` instead.
|
|
7
|
+
*
|
|
8
|
+
* So both methods fail, and they fail loudly rather than pretending. A caller
|
|
9
|
+
* that needs a real process is a caller this host cannot serve, and the plugin
|
|
10
|
+
* profile is what keeps those callers out — not a stub that returns nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ChildProcess, ExecFileResult, ProcessRunner } from '@roj-ai/sdk/platform'
|
|
14
|
+
|
|
15
|
+
function unsupported(what: string): Error {
|
|
16
|
+
return Object.assign(new Error(`${what} is not available: this host has no process table`), { code: 'ENOSYS' })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createUnsupportedProcessRunner(): ProcessRunner {
|
|
20
|
+
return {
|
|
21
|
+
execFile(file: string): Promise<ExecFileResult> {
|
|
22
|
+
return Promise.reject(unsupported(`execFile(${file})`))
|
|
23
|
+
},
|
|
24
|
+
spawn(command: string): ChildProcess {
|
|
25
|
+
throw unsupported(`spawn(${command})`)
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `session.log` as rows rather than a file in the workspace.
|
|
3
|
+
*
|
|
4
|
+
* The workspace filesystem is itself SQLite, so appending a log line means
|
|
5
|
+
* reading the file's length and writing a range through it, where a bound
|
|
6
|
+
* INSERT is one statement. The session log is the great majority of the write
|
|
7
|
+
* operations a turn makes, so the file shape is what a turn spends its I/O on.
|
|
8
|
+
*
|
|
9
|
+
* Clustered on `(session_id, seq)` `WITHOUT ROWID`: one session's lines are
|
|
10
|
+
* contiguous and a tail read is a seek down the key rather than a scan.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SessionLogPage, SessionLogStore } from '@roj-ai/sdk/platform'
|
|
14
|
+
import type { SqlDatabase } from '@kompjutr/do'
|
|
15
|
+
|
|
16
|
+
const LOG_TABLE = 'roj_session_log'
|
|
17
|
+
|
|
18
|
+
export class KompjutrSessionLog implements SessionLogStore {
|
|
19
|
+
/** Next seq per session, so an append does not re-derive MAX(seq) per line. */
|
|
20
|
+
readonly #nextSeq = new Map<string, number>()
|
|
21
|
+
|
|
22
|
+
constructor(private readonly db: SqlDatabase) {
|
|
23
|
+
this.db.run(
|
|
24
|
+
`CREATE TABLE IF NOT EXISTS ${LOG_TABLE} (
|
|
25
|
+
session_id TEXT NOT NULL,
|
|
26
|
+
seq INTEGER NOT NULL,
|
|
27
|
+
line TEXT NOT NULL,
|
|
28
|
+
PRIMARY KEY (session_id, seq)
|
|
29
|
+
) WITHOUT ROWID`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
append(sessionId: string, line: string): void {
|
|
34
|
+
try {
|
|
35
|
+
const seq = this.#claimSeq(sessionId)
|
|
36
|
+
this.db.run(`INSERT INTO ${LOG_TABLE} (session_id, seq, line) VALUES (?, ?, ?)`, sessionId, seq, line)
|
|
37
|
+
} catch {
|
|
38
|
+
// A dropped line must never fail the caller — nothing awaits logging, and
|
|
39
|
+
// the file logger swallows a failed append for the same reason. The cached
|
|
40
|
+
// seq goes with it: if this collided, keeping it would collide forever.
|
|
41
|
+
this.#nextSeq.delete(sessionId)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async read(sessionId: string, since: number): Promise<SessionLogPage> {
|
|
46
|
+
const rows = this.db.all<{ seq: number; line: string }>(
|
|
47
|
+
`SELECT seq, line FROM ${LOG_TABLE} WHERE session_id = ? AND seq > ? ORDER BY seq`,
|
|
48
|
+
sessionId,
|
|
49
|
+
since,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const last = rows[rows.length - 1]
|
|
53
|
+
if (last !== undefined) return { lines: rows.map((row) => row.line), offset: last.seq }
|
|
54
|
+
|
|
55
|
+
// Nothing after `since`: report the tail, so a cursor left past the end — a
|
|
56
|
+
// reaped session, a fresh table — rewinds instead of sticking there.
|
|
57
|
+
return { lines: [], offset: Math.min(since, this.#maxSeq(sessionId)) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(sessionId: string): Promise<number> {
|
|
61
|
+
const lines = this.db.scalar<number>(`SELECT COUNT(*) FROM ${LOG_TABLE} WHERE session_id = ?`, sessionId) ?? 0
|
|
62
|
+
this.db.run(`DELETE FROM ${LOG_TABLE} WHERE session_id = ?`, sessionId)
|
|
63
|
+
// Per-session state; a deleted session must not keep a seq to continue from.
|
|
64
|
+
this.#nextSeq.delete(sessionId)
|
|
65
|
+
return lines
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 1-based, so the default `since: 0` includes the very first line. */
|
|
69
|
+
#claimSeq(sessionId: string): number {
|
|
70
|
+
const seq = this.#nextSeq.get(sessionId) ?? this.#maxSeq(sessionId) + 1
|
|
71
|
+
this.#nextSeq.set(sessionId, seq + 1)
|
|
72
|
+
return seq
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Last stored seq, 0 when the session has none. */
|
|
76
|
+
#maxSeq(sessionId: string): number {
|
|
77
|
+
return this.db.scalar<number | null>(`SELECT MAX(seq) FROM ${LOG_TABLE} WHERE session_id = ?`, sessionId) ?? 0
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ShellRunner } from '@roj-ai/sdk/platform'
|
|
2
|
+
import type { Filesystem, Git } from '@kompjutr/do'
|
|
3
|
+
import { createGitCommand } from '@kompjutr/do/git-shell'
|
|
4
|
+
import { createShell } from '@kompjutr/do/shell'
|
|
5
|
+
|
|
6
|
+
export interface KompjutrShellRunnerOptions {
|
|
7
|
+
filesystem: Filesystem
|
|
8
|
+
/** Adds the `git` command when the workspace was configured with Git. */
|
|
9
|
+
git?: Git
|
|
10
|
+
/** `host` is valid only when this workspace contains one session's files. */
|
|
11
|
+
confinement?: 'host' | 'none'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function quoteShellWord(value: string): string {
|
|
15
|
+
return `'${value.replaceAll("'", "'\\''")}'`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createKompjutrShellRunner(options: KompjutrShellRunnerOptions): ShellRunner {
|
|
19
|
+
const commands = options.git === undefined ? undefined : new Map([['git', createGitCommand(options.git)]])
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
confinement: options.confinement ?? 'none',
|
|
23
|
+
supportsTimeout: false,
|
|
24
|
+
async run(runOptions) {
|
|
25
|
+
if (!runOptions.cwd.startsWith('/')) throw new Error('kompjutr shell requires an absolute cwd')
|
|
26
|
+
if ((runOptions.grants?.length ?? 0) > 0) {
|
|
27
|
+
throw new Error('kompjutr shell does not support path grants')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ShellSession caches cwd per instance, even though instances share the default SQL row.
|
|
31
|
+
const shell = createShell({ fs: options.filesystem, commands })
|
|
32
|
+
const cwdResult = await shell.run(`cd ${quoteShellWord(runOptions.cwd)}`)
|
|
33
|
+
const result =
|
|
34
|
+
cwdResult.exitCode === 0
|
|
35
|
+
? await shell.run(runOptions.command, {
|
|
36
|
+
env: runOptions.env,
|
|
37
|
+
stdin: runOptions.stdin,
|
|
38
|
+
})
|
|
39
|
+
: cwdResult
|
|
40
|
+
return {
|
|
41
|
+
stdout: result.stdout,
|
|
42
|
+
stderr: result.stderr,
|
|
43
|
+
exitCode: result.exitCode,
|
|
44
|
+
timedOut: false,
|
|
45
|
+
truncated: result.truncated,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BunSqliteStorage, MAX_BIND_PARAMETERS } from './storage.js'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun:sqlite` standing in for Durable Object SQL storage, so the adapter can
|
|
3
|
+
* be driven by `bun test` without a Worker runtime.
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately *stricter* than its backing engine on one point. A Durable
|
|
6
|
+
* Object binds at most 100 parameters per statement; bun:sqlite binds far more,
|
|
7
|
+
* and an earlier attempt at this port shipped a batch insert that passed every
|
|
8
|
+
* test and failed in production for exactly that reason. A fake that is more
|
|
9
|
+
* permissive than the thing it stands for hides the bugs worth catching, so the
|
|
10
|
+
* limit is enforced here rather than discovered on a deploy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Database as BunDatabase, type SQLQueryBindings } from 'bun:sqlite'
|
|
14
|
+
import type { DurableObjectStorageLike, SQLCursorLike, SQLStorageLike } from '@kompjutr/do'
|
|
15
|
+
|
|
16
|
+
/** What a Durable Object binds per statement. The 101st raises, as workerd does. */
|
|
17
|
+
export const MAX_BIND_PARAMETERS = 100
|
|
18
|
+
|
|
19
|
+
/** SQLite takes bytes, numbers, strings and null; everything else is converted or refused. */
|
|
20
|
+
function toBinding(value: unknown): SQLQueryBindings {
|
|
21
|
+
if (value === undefined || value === null) return null
|
|
22
|
+
if (typeof value === 'boolean') return value ? 1 : 0
|
|
23
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
24
|
+
if (ArrayBuffer.isView(value) && !(value instanceof Uint8Array)) {
|
|
25
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string' || value instanceof Uint8Array) {
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
throw new TypeError(`unsupported SQLite binding: ${typeof value}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rows arrive lazily, because `Filesystem.scan` pages a range scan and a cursor
|
|
35
|
+
* that materialised would turn every page into the whole table.
|
|
36
|
+
*
|
|
37
|
+
* One step is taken up front all the same: a statement nobody iterates is a
|
|
38
|
+
* statement bun:sqlite never runs, and a `CREATE TABLE` has no rows to pull.
|
|
39
|
+
*/
|
|
40
|
+
class Cursor<Row extends object> implements SQLCursorLike<Row>, IterableIterator<Row> {
|
|
41
|
+
#rows: Iterator<Row>
|
|
42
|
+
#prefetched: IteratorResult<Row> | null
|
|
43
|
+
#done = false
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
rows: Iterator<Row>,
|
|
47
|
+
private readonly onRow: () => void,
|
|
48
|
+
) {
|
|
49
|
+
this.#rows = rows
|
|
50
|
+
this.#prefetched = this.#pull()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#pull(): IteratorResult<Row> {
|
|
54
|
+
const step = this.#rows.next()
|
|
55
|
+
if (step.done === true) this.#done = true
|
|
56
|
+
else this.onRow()
|
|
57
|
+
return step
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
next(): IteratorResult<Row> {
|
|
61
|
+
if (this.#prefetched !== null) {
|
|
62
|
+
const first = this.#prefetched
|
|
63
|
+
this.#prefetched = null
|
|
64
|
+
return first
|
|
65
|
+
}
|
|
66
|
+
if (this.#done) return { done: true, value: undefined }
|
|
67
|
+
return this.#pull()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return(): IteratorResult<Row> {
|
|
71
|
+
if (!this.#done) {
|
|
72
|
+
this.#rows.return?.()
|
|
73
|
+
this.#done = true
|
|
74
|
+
}
|
|
75
|
+
this.#prefetched = null
|
|
76
|
+
return { done: true, value: undefined }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[Symbol.iterator](): IterableIterator<Row> {
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
toArray(): Row[] {
|
|
84
|
+
return Array.from(this)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class BunSqliteStorage implements DurableObjectStorageLike {
|
|
89
|
+
readonly db: BunDatabase
|
|
90
|
+
readonly sql: SQLStorageLike
|
|
91
|
+
|
|
92
|
+
/** Statements executed since the last reset — what a cost test asserts on. */
|
|
93
|
+
statementCount = 0
|
|
94
|
+
/** Rows the caller actually pulled. Approximate: a cursor abandoned early stops counting. */
|
|
95
|
+
rowCount = 0
|
|
96
|
+
|
|
97
|
+
#depth = 0
|
|
98
|
+
|
|
99
|
+
constructor(path = ':memory:') {
|
|
100
|
+
this.db = new BunDatabase(path, { create: true })
|
|
101
|
+
this.db.run('PRAGMA journal_mode = WAL')
|
|
102
|
+
this.sql = {
|
|
103
|
+
exec: <Row extends object>(query: string, ...bindings: unknown[]): SQLCursorLike<Row> => {
|
|
104
|
+
if (bindings.length > MAX_BIND_PARAMETERS) {
|
|
105
|
+
throw new Error(`too many SQL variables: ${bindings.length} exceeds the ${MAX_BIND_PARAMETERS} a Durable Object binds`)
|
|
106
|
+
}
|
|
107
|
+
this.statementCount++
|
|
108
|
+
const statement = this.db.query<Row, SQLQueryBindings[]>(query)
|
|
109
|
+
const rows = statement.iterate(...bindings.map(toBinding))
|
|
110
|
+
return new Cursor<Row>(rows, () => {
|
|
111
|
+
this.rowCount++
|
|
112
|
+
})
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
transactionSync<T>(closure: () => T): T {
|
|
118
|
+
if (this.#depth > 0) return closure()
|
|
119
|
+
this.#depth++
|
|
120
|
+
this.db.run('BEGIN')
|
|
121
|
+
try {
|
|
122
|
+
const result = closure()
|
|
123
|
+
this.db.run('COMMIT')
|
|
124
|
+
return result
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.db.run('ROLLBACK')
|
|
127
|
+
throw error
|
|
128
|
+
} finally {
|
|
129
|
+
this.#depth--
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
resetCounters(): void {
|
|
134
|
+
this.statementCount = 0
|
|
135
|
+
this.rowCount = 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
close(): void {
|
|
139
|
+
this.db.close()
|
|
140
|
+
}
|
|
141
|
+
}
|