@nifrajs/coding-agent 3.3.0 → 3.5.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/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +5 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +138 -34
- package/dist/native.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +208 -29
- package/dist/rpc.js.map +1 -1
- package/dist/sessions.d.ts +14 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +253 -59
- package/dist/sessions.js.map +1 -1
- package/dist/verification.d.ts +23 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +113 -1
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +43 -21
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -5
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/host.ts +146 -23
- package/src/index.ts +5 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +173 -32
- package/src/replay.ts +206 -25
- package/src/rpc.ts +261 -33
- package/src/sessions.ts +271 -56
- package/src/verification.ts +140 -1
- package/src/workflows.ts +37 -25
package/src/sessions.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as FS_CONSTANTS } from "node:fs"
|
|
2
|
+
import { type FileHandle, lstat, mkdir, open, rename, unlink, writeFile } from "node:fs/promises"
|
|
2
3
|
import { dirname, join } from "node:path"
|
|
3
4
|
|
|
5
|
+
const DEFAULT_MAX_SESSION_ENTRIES = 4_096
|
|
6
|
+
const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024
|
|
7
|
+
const MAX_SESSION_ENTRIES = 100_000
|
|
8
|
+
const MAX_SESSION_BYTES = 128 * 1024 * 1024
|
|
9
|
+
// `node:constants` does not expose the POSIX-only named export on Windows in Bun.
|
|
10
|
+
// Read it from the platform-safe `node:fs` constants object and fall back to zero;
|
|
11
|
+
// the Windows open path uses its lstat/open/lstat guard instead of this flag.
|
|
12
|
+
const O_APPEND = FS_CONSTANTS.O_APPEND
|
|
13
|
+
const O_CREAT = FS_CONSTANTS.O_CREAT
|
|
14
|
+
const O_RDONLY = FS_CONSTANTS.O_RDONLY
|
|
15
|
+
const O_WRONLY = FS_CONSTANTS.O_WRONLY
|
|
16
|
+
const O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0
|
|
17
|
+
|
|
4
18
|
export interface SessionLogEntry {
|
|
5
19
|
readonly version: 1
|
|
6
20
|
readonly sessionId: string
|
|
@@ -18,7 +32,8 @@ export interface SessionStore {
|
|
|
18
32
|
payload?: unknown,
|
|
19
33
|
options?: { readonly pinned?: boolean },
|
|
20
34
|
): Promise<SessionLogEntry>
|
|
21
|
-
|
|
35
|
+
/** Read at most `limit` retained entries from the tail of the session log. */
|
|
36
|
+
read(sessionId: string, limit?: number): Promise<readonly SessionLogEntry[]>
|
|
22
37
|
checkpoint(sessionId: string, payload: unknown): Promise<void>
|
|
23
38
|
fork(sessionId: string, targetSessionId?: string): Promise<string>
|
|
24
39
|
}
|
|
@@ -26,6 +41,10 @@ export interface SessionStore {
|
|
|
26
41
|
export interface FileSessionStoreOptions {
|
|
27
42
|
readonly root: string
|
|
28
43
|
readonly maxEntryBytes?: number
|
|
44
|
+
/** Maximum retained JSONL entries per session. Defaults to 4,096. */
|
|
45
|
+
readonly maxEntries?: number
|
|
46
|
+
/** Maximum retained JSONL bytes per session. Defaults to 8 MiB. */
|
|
47
|
+
readonly maxBytes?: number
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
/**
|
|
@@ -35,13 +54,32 @@ export interface FileSessionStoreOptions {
|
|
|
35
54
|
export class FileSessionStore implements SessionStore {
|
|
36
55
|
private readonly root: string
|
|
37
56
|
private readonly maxEntryBytes: number
|
|
57
|
+
private readonly maxEntries: number
|
|
58
|
+
private readonly maxBytes: number
|
|
38
59
|
private readonly sequences = new Map<string, number>()
|
|
60
|
+
private readonly locks = new Map<string, Promise<void>>()
|
|
39
61
|
|
|
40
62
|
constructor(options: FileSessionStoreOptions) {
|
|
41
63
|
this.root = options.root
|
|
42
64
|
this.maxEntryBytes = options.maxEntryBytes ?? 256 * 1024
|
|
43
65
|
if (!Number.isSafeInteger(this.maxEntryBytes) || this.maxEntryBytes < 1024)
|
|
44
66
|
throw new RangeError("session store: maxEntryBytes must be at least 1024")
|
|
67
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_SESSION_ENTRIES
|
|
68
|
+
if (
|
|
69
|
+
!Number.isSafeInteger(this.maxEntries) ||
|
|
70
|
+
this.maxEntries < 1 ||
|
|
71
|
+
this.maxEntries > MAX_SESSION_ENTRIES
|
|
72
|
+
)
|
|
73
|
+
throw new RangeError(`session store: maxEntries must be between 1 and ${MAX_SESSION_ENTRIES}`)
|
|
74
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_SESSION_BYTES
|
|
75
|
+
if (
|
|
76
|
+
!Number.isSafeInteger(this.maxBytes) ||
|
|
77
|
+
this.maxBytes < this.maxEntryBytes + 1 ||
|
|
78
|
+
this.maxBytes > MAX_SESSION_BYTES
|
|
79
|
+
)
|
|
80
|
+
throw new RangeError(
|
|
81
|
+
`session store: maxBytes must be between maxEntryBytes+1 and ${MAX_SESSION_BYTES}`,
|
|
82
|
+
)
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
async append(
|
|
@@ -53,59 +91,65 @@ export class FileSessionStore implements SessionStore {
|
|
|
53
91
|
validateToken(sessionId, "sessionId")
|
|
54
92
|
if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(type))
|
|
55
93
|
throw new TypeError("session store: invalid event type")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
return this.withSessionLock(sessionId, async () => {
|
|
95
|
+
await mkdir(this.root, { recursive: true })
|
|
96
|
+
const nextSeq = await this.nextSequence(sessionId)
|
|
97
|
+
const entry: SessionLogEntry = Object.freeze({
|
|
98
|
+
version: 1,
|
|
99
|
+
sessionId,
|
|
100
|
+
seq: nextSeq,
|
|
101
|
+
at: Date.now(),
|
|
102
|
+
type,
|
|
103
|
+
...(payload === undefined
|
|
104
|
+
? {}
|
|
105
|
+
: { payload: boundValue(redactValue(payload), this.maxEntryBytes) }),
|
|
106
|
+
...(options.pinned === true ? { pinned: true } : {}),
|
|
107
|
+
})
|
|
108
|
+
const line = JSON.stringify(entry)
|
|
109
|
+
if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
|
|
110
|
+
throw new RangeError("session store: event exceeds maxEntryBytes")
|
|
111
|
+
const path = this.pathFor(sessionId)
|
|
112
|
+
const file = await openSessionFile(path, O_APPEND | O_CREAT | O_WRONLY, 0o600)
|
|
113
|
+
try {
|
|
114
|
+
await file.writeFile(`${line}\n`, "utf8")
|
|
115
|
+
} finally {
|
|
116
|
+
await file.close()
|
|
117
|
+
}
|
|
118
|
+
this.sequences.set(sessionId, nextSeq)
|
|
119
|
+
await this.compactIfNeeded(sessionId)
|
|
120
|
+
return entry
|
|
68
121
|
})
|
|
69
|
-
const line = JSON.stringify(entry)
|
|
70
|
-
if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
|
|
71
|
-
throw new RangeError("session store: event exceeds maxEntryBytes")
|
|
72
|
-
await appendFile(this.pathFor(sessionId), `${line}\n`, "utf8")
|
|
73
|
-
this.sequences.set(sessionId, nextSeq)
|
|
74
|
-
return entry
|
|
75
122
|
}
|
|
76
123
|
|
|
77
|
-
async read(sessionId: string): Promise<readonly SessionLogEntry[]> {
|
|
124
|
+
async read(sessionId: string, limit = this.maxEntries): Promise<readonly SessionLogEntry[]> {
|
|
78
125
|
validateToken(sessionId, "sessionId")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (line.trim().length === 0) continue
|
|
84
|
-
const value: unknown = JSON.parse(line)
|
|
85
|
-
if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
|
|
86
|
-
}
|
|
87
|
-
const last = entries.at(-1)?.seq
|
|
88
|
-
if (last !== undefined) this.sequences.set(sessionId, last)
|
|
89
|
-
return Object.freeze(entries)
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (isNotFound(error)) return Object.freeze([])
|
|
92
|
-
throw error
|
|
93
|
-
}
|
|
126
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
127
|
+
throw new RangeError("session store: read limit must be a positive safe integer")
|
|
128
|
+
const boundedLimit = Math.min(limit, this.maxEntries)
|
|
129
|
+
return this.withSessionLock(sessionId, () => this.readUnlocked(sessionId, boundedLimit))
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
async checkpoint(sessionId: string, payload: unknown): Promise<void> {
|
|
97
133
|
validateToken(sessionId, "sessionId")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
await this.withSessionLock(sessionId, async () => {
|
|
135
|
+
const target = join(this.root, `${sessionId}.checkpoint.json`)
|
|
136
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
137
|
+
await mkdir(dirname(target), { recursive: true })
|
|
138
|
+
const content = JSON.stringify({
|
|
139
|
+
version: 1,
|
|
140
|
+
sessionId,
|
|
141
|
+
at: Date.now(),
|
|
142
|
+
payload: boundValue(redactValue(payload), this.maxEntryBytes),
|
|
143
|
+
})
|
|
144
|
+
// Exclusive creation prevents a local peer from planting a symlink at the temporary path.
|
|
145
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
|
|
146
|
+
try {
|
|
147
|
+
await rename(temporary, target)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await unlink(temporary).catch(() => {})
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
106
152
|
})
|
|
107
|
-
await writeFile(temporary, content, "utf8")
|
|
108
|
-
await rename(temporary, target)
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
async fork(
|
|
@@ -114,15 +158,23 @@ export class FileSessionStore implements SessionStore {
|
|
|
114
158
|
): Promise<string> {
|
|
115
159
|
validateToken(sessionId, "sessionId")
|
|
116
160
|
validateToken(targetSessionId, "targetSessionId")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
return this.withSessionLock(sessionId, async () => {
|
|
162
|
+
const entries = await this.readUnlocked(sessionId, this.maxEntries)
|
|
163
|
+
await mkdir(this.root, { recursive: true })
|
|
164
|
+
const target = this.pathFor(targetSessionId)
|
|
165
|
+
const lines = entries.map((entry, index) =>
|
|
166
|
+
JSON.stringify({ ...entry, sessionId: targetSessionId, seq: index }),
|
|
167
|
+
)
|
|
168
|
+
// A caller must never be able to overwrite an existing session by choosing its id. The
|
|
169
|
+
// exclusive create also refuses a pre-planted symlink at the destination.
|
|
170
|
+
await writeFile(target, lines.length === 0 ? "" : `${lines.join("\n")}\n`, {
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
flag: "wx",
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
})
|
|
175
|
+
this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
|
|
176
|
+
return targetSessionId
|
|
177
|
+
})
|
|
126
178
|
}
|
|
127
179
|
|
|
128
180
|
private pathFor(sessionId: string): string {
|
|
@@ -132,9 +184,108 @@ export class FileSessionStore implements SessionStore {
|
|
|
132
184
|
private async nextSequence(sessionId: string): Promise<number> {
|
|
133
185
|
const cached = this.sequences.get(sessionId)
|
|
134
186
|
if (cached !== undefined) return cached + 1
|
|
135
|
-
const entries = await this.
|
|
187
|
+
const entries = await this.readUnlocked(sessionId, 1)
|
|
136
188
|
return (entries.at(-1)?.seq ?? -1) + 1
|
|
137
189
|
}
|
|
190
|
+
|
|
191
|
+
private async compactIfNeeded(sessionId: string): Promise<void> {
|
|
192
|
+
const path = this.pathFor(sessionId)
|
|
193
|
+
const file = await openSessionFile(path, O_RDONLY)
|
|
194
|
+
let size: number
|
|
195
|
+
try {
|
|
196
|
+
size = (await file.stat()).size
|
|
197
|
+
} finally {
|
|
198
|
+
await file.close()
|
|
199
|
+
}
|
|
200
|
+
if (size <= this.maxBytes) {
|
|
201
|
+
const recent = parseSessionEntries(
|
|
202
|
+
await this.readTailText(path, this.maxEntries + 1, this.maxBytes),
|
|
203
|
+
sessionId,
|
|
204
|
+
)
|
|
205
|
+
if (recent.length <= this.maxEntries) return
|
|
206
|
+
}
|
|
207
|
+
// The tail read is bounded by the retention cap plus one maximum-sized line. This keeps
|
|
208
|
+
// compaction memory bounded even if a pre-existing log is already oversized.
|
|
209
|
+
const recent = parseSessionEntries(
|
|
210
|
+
await this.readTailText(path, this.maxEntries + 1, this.maxBytes + this.maxEntryBytes),
|
|
211
|
+
sessionId,
|
|
212
|
+
)
|
|
213
|
+
const retained = retainEntries(recent, this.maxEntries, this.maxBytes)
|
|
214
|
+
if (retained.length === 0) return
|
|
215
|
+
const content = `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n`
|
|
216
|
+
if (Buffer.byteLength(content, "utf8") >= size && size <= this.maxBytes) return
|
|
217
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
218
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
|
|
219
|
+
try {
|
|
220
|
+
await rename(temporary, path)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await unlink(temporary).catch(() => {})
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
this.sequences.set(sessionId, retained.at(-1)!.seq)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async readTailText(
|
|
229
|
+
path: string,
|
|
230
|
+
minimumLines: number,
|
|
231
|
+
maxBytes: number,
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const file = await openSessionFile(path, O_RDONLY)
|
|
234
|
+
try {
|
|
235
|
+
const size = (await file.stat()).size
|
|
236
|
+
let end = size
|
|
237
|
+
let remaining = Math.min(size, maxBytes)
|
|
238
|
+
const chunks: Buffer[] = []
|
|
239
|
+
const chunkBytes = 64 * 1024
|
|
240
|
+
while (remaining > 0) {
|
|
241
|
+
const take = Math.min(remaining, chunkBytes)
|
|
242
|
+
end -= take
|
|
243
|
+
const bytes = Buffer.allocUnsafe(take)
|
|
244
|
+
const result = await file.read(bytes, 0, take, end)
|
|
245
|
+
chunks.unshift(bytes.subarray(0, result.bytesRead))
|
|
246
|
+
remaining -= result.bytesRead
|
|
247
|
+
const text = Buffer.concat(chunks).toString("utf8")
|
|
248
|
+
const lines = text.split("\n")
|
|
249
|
+
const completeLines = end > 0 ? lines.length - 1 : lines.length
|
|
250
|
+
if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0) return text
|
|
251
|
+
}
|
|
252
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
253
|
+
} finally {
|
|
254
|
+
await file.close()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async readUnlocked(
|
|
259
|
+
sessionId: string,
|
|
260
|
+
boundedLimit: number,
|
|
261
|
+
): Promise<readonly SessionLogEntry[]> {
|
|
262
|
+
try {
|
|
263
|
+
const text = await this.readTailText(this.pathFor(sessionId), boundedLimit, this.maxBytes)
|
|
264
|
+
const entries = parseSessionEntries(text, sessionId)
|
|
265
|
+
const last = entries.at(-1)?.seq
|
|
266
|
+
if (last !== undefined) this.sequences.set(sessionId, last)
|
|
267
|
+
return Object.freeze(entries.slice(-boundedLimit))
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (isNotFound(error)) return Object.freeze([])
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async withSessionLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
275
|
+
const previous = this.locks.get(sessionId) ?? Promise.resolve()
|
|
276
|
+
let release!: () => void
|
|
277
|
+
const current = new Promise<void>((resolve) => {
|
|
278
|
+
release = resolve
|
|
279
|
+
})
|
|
280
|
+
this.locks.set(sessionId, current)
|
|
281
|
+
await previous
|
|
282
|
+
try {
|
|
283
|
+
return await operation()
|
|
284
|
+
} finally {
|
|
285
|
+
release()
|
|
286
|
+
if (this.locks.get(sessionId) === current) this.locks.delete(sessionId)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
138
289
|
}
|
|
139
290
|
|
|
140
291
|
export interface ContextRecord {
|
|
@@ -235,6 +386,38 @@ export class ContextWindow {
|
|
|
235
386
|
}
|
|
236
387
|
}
|
|
237
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Open a session file without following a final-component symlink. Windows Bun does not support
|
|
391
|
+
* the POSIX `O_NOFOLLOW` flag, so it gets a bounded lstat/open/lstat guard instead; the descriptor is
|
|
392
|
+
* checked before any caller write or read. POSIX keeps the kernel-enforced flag for the race-free path.
|
|
393
|
+
*/
|
|
394
|
+
async function openSessionFile(path: string, flags: number, mode?: number): Promise<FileHandle> {
|
|
395
|
+
if (process.platform !== "win32") {
|
|
396
|
+
return mode === undefined
|
|
397
|
+
? open(path, flags | O_NOFOLLOW)
|
|
398
|
+
: open(path, flags | O_NOFOLLOW, mode)
|
|
399
|
+
}
|
|
400
|
+
await rejectSessionSymlink(path)
|
|
401
|
+
const file = mode === undefined ? await open(path, flags) : await open(path, flags, mode)
|
|
402
|
+
try {
|
|
403
|
+
await rejectSessionSymlink(path)
|
|
404
|
+
return file
|
|
405
|
+
} catch (error) {
|
|
406
|
+
await file.close()
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function rejectSessionSymlink(path: string): Promise<void> {
|
|
412
|
+
try {
|
|
413
|
+
if ((await lstat(path)).isSymbolicLink())
|
|
414
|
+
throw new Error("session store: refusing a symbolic-link session file")
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (isNotFound(error)) return
|
|
417
|
+
throw error
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
238
421
|
function validateToken(value: string, name: string): void {
|
|
239
422
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
|
|
240
423
|
throw new TypeError(`session store: ${name} must be a bounded token`)
|
|
@@ -257,6 +440,38 @@ function isSessionLogEntry(value: unknown, sessionId: string): value is SessionL
|
|
|
257
440
|
)
|
|
258
441
|
}
|
|
259
442
|
|
|
443
|
+
function parseSessionEntries(text: string, sessionId: string): SessionLogEntry[] {
|
|
444
|
+
const entries: SessionLogEntry[] = []
|
|
445
|
+
for (const line of text.split("\n")) {
|
|
446
|
+
if (line.trim().length === 0) continue
|
|
447
|
+
try {
|
|
448
|
+
const value: unknown = JSON.parse(line)
|
|
449
|
+
if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
|
|
450
|
+
} catch {
|
|
451
|
+
// A tail read can begin in the middle of an old line. Invalid records are ignored so a
|
|
452
|
+
// damaged/partially rotated log cannot turn every history request into an unbounded retry.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return entries
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function retainEntries(
|
|
459
|
+
entries: readonly SessionLogEntry[],
|
|
460
|
+
maxEntries: number,
|
|
461
|
+
maxBytes: number,
|
|
462
|
+
): readonly SessionLogEntry[] {
|
|
463
|
+
const retained: SessionLogEntry[] = []
|
|
464
|
+
let bytes = 0
|
|
465
|
+
for (let index = entries.length - 1; index >= 0 && retained.length < maxEntries; index--) {
|
|
466
|
+
const entry = entries[index]!
|
|
467
|
+
const size = Buffer.byteLength(JSON.stringify(entry), "utf8") + 1
|
|
468
|
+
if (bytes + size > maxBytes) break
|
|
469
|
+
retained.unshift(entry)
|
|
470
|
+
bytes += size
|
|
471
|
+
}
|
|
472
|
+
return retained
|
|
473
|
+
}
|
|
474
|
+
|
|
260
475
|
function redactValue(value: unknown, key = "", depth = 0): unknown {
|
|
261
476
|
if (depth > 5) return "[depth-limited]"
|
|
262
477
|
if (/(?:secret|token|password|passwd|authorization|api[-_]?key|private[-_]?key)/i.test(key))
|
package/src/verification.ts
CHANGED
|
@@ -53,6 +53,114 @@ export interface VerificationOptions {
|
|
|
53
53
|
readonly exposeErrorStacks?: boolean
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export type ReviewExecutionErrorCode =
|
|
57
|
+
| "timeout"
|
|
58
|
+
| "output-truncated"
|
|
59
|
+
| "invalid-report"
|
|
60
|
+
| "spawn-failed"
|
|
61
|
+
|
|
62
|
+
export interface ReviewOptions {
|
|
63
|
+
readonly cwd: string
|
|
64
|
+
readonly command?: string
|
|
65
|
+
readonly commandArgs?: readonly string[]
|
|
66
|
+
readonly strict?: boolean
|
|
67
|
+
readonly diff?: string
|
|
68
|
+
readonly timeoutMs?: number
|
|
69
|
+
readonly maxOutputBytes?: number
|
|
70
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ReviewExecutionResult {
|
|
74
|
+
readonly name: "review"
|
|
75
|
+
readonly ok: boolean
|
|
76
|
+
readonly status: number | null
|
|
77
|
+
/** A parsed, content-free ReviewReport. Raw stdout/stderr is intentionally never returned. */
|
|
78
|
+
readonly report?: unknown
|
|
79
|
+
readonly errorCode?: ReviewExecutionErrorCode
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const REVIEW_GIT_REF = /^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,255}$/
|
|
83
|
+
|
|
84
|
+
/** Validate the only repository selector exposed by the host review RPC. */
|
|
85
|
+
export function isSafeReviewDiff(value: unknown): value is string {
|
|
86
|
+
return (
|
|
87
|
+
typeof value === "string" &&
|
|
88
|
+
REVIEW_GIT_REF.test(value) &&
|
|
89
|
+
!value.includes("..") &&
|
|
90
|
+
!value.includes("@{") &&
|
|
91
|
+
!value.includes("//") &&
|
|
92
|
+
!value.endsWith(".") &&
|
|
93
|
+
!value.endsWith("/") &&
|
|
94
|
+
!value
|
|
95
|
+
.split("/")
|
|
96
|
+
.some((part) => part.length === 0 || part === "." || part === ".." || part.startsWith("."))
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run `nifra review --json` with the same bounded process discipline as the existing gates. */
|
|
101
|
+
export async function runNifraReview(options: ReviewOptions): Promise<ReviewExecutionResult> {
|
|
102
|
+
if (options.strict !== undefined && typeof options.strict !== "boolean")
|
|
103
|
+
throw new TypeError("review strict must be a boolean")
|
|
104
|
+
if (options.diff !== undefined && !isSafeReviewDiff(options.diff))
|
|
105
|
+
throw new TypeError("review diff is not a safe Git ref")
|
|
106
|
+
const command = options.command ?? "nifra"
|
|
107
|
+
const commandArgs = options.commandArgs ?? []
|
|
108
|
+
const args = ["review", "--json"]
|
|
109
|
+
if (options.strict === true) args.push("--strict")
|
|
110
|
+
if (options.diff !== undefined) args.push("--diff", options.diff)
|
|
111
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
112
|
+
try {
|
|
113
|
+
proc = Bun.spawn([command, ...commandArgs, ...args], {
|
|
114
|
+
cwd: options.cwd,
|
|
115
|
+
stdin: "ignore",
|
|
116
|
+
stdout: "pipe",
|
|
117
|
+
stderr: "pipe",
|
|
118
|
+
env: safeEnv(options.env),
|
|
119
|
+
})
|
|
120
|
+
} catch {
|
|
121
|
+
return { name: "review", ok: false, status: null, errorCode: "spawn-failed" }
|
|
122
|
+
}
|
|
123
|
+
const timeoutMs = options.timeoutMs ?? 120_000
|
|
124
|
+
let timedOut = false
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
timedOut = true
|
|
127
|
+
proc.kill()
|
|
128
|
+
}, timeoutMs)
|
|
129
|
+
try {
|
|
130
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
131
|
+
readBoundedText(proc.stdout, options.maxOutputBytes ?? 1_048_576),
|
|
132
|
+
readBoundedText(proc.stderr, options.maxOutputBytes ?? 1_048_576),
|
|
133
|
+
proc.exited,
|
|
134
|
+
])
|
|
135
|
+
void stderr
|
|
136
|
+
if (timedOut) return { name: "review", ok: false, status, errorCode: "timeout" }
|
|
137
|
+
if (stdout.truncated || stderr.truncated)
|
|
138
|
+
return { name: "review", ok: false, status, errorCode: "output-truncated" }
|
|
139
|
+
try {
|
|
140
|
+
const raw = JSON.parse(stdout.text) as unknown
|
|
141
|
+
const { parseReviewReport } = await import("@nifrajs/agent-review")
|
|
142
|
+
const report = await parseReviewReport(raw)
|
|
143
|
+
return {
|
|
144
|
+
name: "review",
|
|
145
|
+
ok: status === 0 && report.ok,
|
|
146
|
+
status,
|
|
147
|
+
report,
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
return { name: "review", ok: false, status, errorCode: "invalid-report" }
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
return {
|
|
154
|
+
name: "review",
|
|
155
|
+
ok: false,
|
|
156
|
+
status: null,
|
|
157
|
+
errorCode: timedOut ? "timeout" : "spawn-failed",
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(timer)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
56
164
|
/** Run an existing Nifra gate without importing the large framework CLI into the agent runtime. */
|
|
57
165
|
export async function runNifraVerification(
|
|
58
166
|
name: "check" | "assure" | "test",
|
|
@@ -124,10 +232,41 @@ function safeEnv(
|
|
|
124
232
|
"LC_ALL",
|
|
125
233
|
"TERM",
|
|
126
234
|
"CI",
|
|
235
|
+
...(process.platform === "win32"
|
|
236
|
+
? [
|
|
237
|
+
"SystemRoot",
|
|
238
|
+
"WINDIR",
|
|
239
|
+
"TEMP",
|
|
240
|
+
"TMP",
|
|
241
|
+
"USERPROFILE",
|
|
242
|
+
"LOCALAPPDATA",
|
|
243
|
+
"APPDATA",
|
|
244
|
+
"COMSPEC",
|
|
245
|
+
"PATHEXT",
|
|
246
|
+
"BUN_INSTALL",
|
|
247
|
+
"HOMEDRIVE",
|
|
248
|
+
"HOMEPATH",
|
|
249
|
+
"USERNAME",
|
|
250
|
+
"USERDOMAIN",
|
|
251
|
+
"ProgramData",
|
|
252
|
+
"ProgramFiles",
|
|
253
|
+
"ProgramFiles(x86)",
|
|
254
|
+
]
|
|
255
|
+
: []),
|
|
127
256
|
...Object.keys(overrides ?? {}),
|
|
128
257
|
])) {
|
|
129
|
-
const value =
|
|
258
|
+
const value =
|
|
259
|
+
overrides !== undefined && Object.hasOwn(overrides, name) ? overrides[name] : readEnv(name)
|
|
130
260
|
if (value !== undefined) result[name] = value
|
|
131
261
|
}
|
|
132
262
|
return result
|
|
133
263
|
}
|
|
264
|
+
|
|
265
|
+
function readEnv(name: string): string | undefined {
|
|
266
|
+
const exact = process.env[name]
|
|
267
|
+
if (exact !== undefined || process.platform !== "win32") return exact
|
|
268
|
+
const key = Object.keys(process.env).find(
|
|
269
|
+
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
|
|
270
|
+
)
|
|
271
|
+
return key === undefined ? undefined : process.env[key]
|
|
272
|
+
}
|