@akira-tl/forgerelay 0.5.0 → 0.5.2
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 +21 -0
- package/dist/activity/audit-store.js +11 -0
- package/dist/activity/bash-output-store.js +127 -0
- package/dist/activity/lifecycle.js +138 -0
- package/dist/activity/process-output-audit.js +1 -0
- package/dist/db/migrations.js +48 -0
- package/dist/db/schema.js +36 -1
- package/dist/lsp/test-support/server-fixture.js +10 -2
- package/dist/process-sessions.js +50 -8
- package/dist/server.js +404 -140
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.2] - 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added durable Bash output audit streams in ForgeRelay's local SQLite state: complete commands and original stdout/stderr/PTY output are retained under a stable `outputId`, can be retrieved after restart through regular Bash or Codex-compatible process tooling, and remain independent of the bounded in-memory process buffer.
|
|
12
|
+
- Background commands that were previously returned to the Host now produce a separate durable `bash_result` Activity exactly once when their completion is delivered, while the original Bash Activity remains historical `returned` state.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Normal Bash, `exec_command`, and process-control responses now keep Agent context compact by returning only the final 10 output lines plus the stable full-output identifier; explicit output lookup returns the complete persisted process output.
|
|
17
|
+
|
|
18
|
+
## [0.5.1] - 2026-08-14
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Routed ForgeRelay's top-level work operations through one persistent Activity lifecycle: read, write, edit, rename, delete, capability, Bash, and Codex-compatible execution/patch operations now record durable started/succeeded/failed/blocked/returned facts without relying on UI inference. Bash/exec process-control follow-ups remain part of the existing semantic operation instead of creating duplicate top-level Activities.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Activity auditing now treats a shell process as `returned` only after its `processId` can actually be delivered to the Host; Host cancellation during post-tool delivery protection records a failed Activity and discards the undelivered process instead of leaving a false returned history entry.
|
|
27
|
+
|
|
7
28
|
## [0.5.0] - 2026-08-14
|
|
8
29
|
|
|
9
30
|
### Added
|
|
@@ -66,6 +66,11 @@ export class ActivityAuditStore {
|
|
|
66
66
|
result = event.result;
|
|
67
67
|
error = undefined;
|
|
68
68
|
break;
|
|
69
|
+
case "returned":
|
|
70
|
+
state = "returned";
|
|
71
|
+
result = event.result;
|
|
72
|
+
error = undefined;
|
|
73
|
+
break;
|
|
69
74
|
case "failed":
|
|
70
75
|
state = "failed";
|
|
71
76
|
result = event.result;
|
|
@@ -177,6 +182,12 @@ function rowToEvent(row) {
|
|
|
177
182
|
type: "succeeded",
|
|
178
183
|
result: parseJson(row.result_json),
|
|
179
184
|
};
|
|
185
|
+
case "returned":
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
type: "returned",
|
|
189
|
+
result: parseJson(row.result_json),
|
|
190
|
+
};
|
|
180
191
|
case "failed":
|
|
181
192
|
if (!row.error)
|
|
182
193
|
throw new Error(`Activity audit failed event ${row.id} is missing an error.`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { openDatabase } from "../db/client.js";
|
|
4
|
+
export class BashOutputStore {
|
|
5
|
+
database;
|
|
6
|
+
now;
|
|
7
|
+
nextOutputId;
|
|
8
|
+
nextSequences = new Map();
|
|
9
|
+
constructor(stateDir, options = {}) {
|
|
10
|
+
this.database = openDatabase(stateDir);
|
|
11
|
+
this.now = options.now ?? (() => new Date());
|
|
12
|
+
this.nextOutputId = options.outputId ?? (() => `out_${randomUUID().replaceAll("-", "")}`);
|
|
13
|
+
}
|
|
14
|
+
begin(input) {
|
|
15
|
+
const outputId = this.nextOutputId();
|
|
16
|
+
const startedAt = this.now().toISOString();
|
|
17
|
+
this.database.sqlite.prepare(`insert into bash_output_streams (
|
|
18
|
+
id, activity_id, turn_id, conversation_scope_id, process_id,
|
|
19
|
+
workspace_id, workspace_root, command, tty, status, timed_out, started_at
|
|
20
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 0, ?)`).run(outputId, input.activityId, input.turnId, input.conversationScopeId ?? null, input.processId, input.workspaceId, input.workspaceRoot, input.command, input.tty ? 1 : 0, startedAt);
|
|
21
|
+
this.nextSequences.set(outputId, 1);
|
|
22
|
+
return outputId;
|
|
23
|
+
}
|
|
24
|
+
append(outputId, channel, data) {
|
|
25
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
26
|
+
if (bytes.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
const sequence = this.nextSequence(outputId);
|
|
29
|
+
this.database.sqlite.prepare(`insert into bash_output_chunks (output_id, sequence, channel, data, created_at)
|
|
30
|
+
values (?, ?, ?, ?, ?)`).run(outputId, sequence, channel, bytes, this.now().toISOString());
|
|
31
|
+
}
|
|
32
|
+
markReturned(outputId) {
|
|
33
|
+
this.database.sqlite.prepare("update bash_output_streams set returned = 1 where id = ?").run(outputId);
|
|
34
|
+
}
|
|
35
|
+
claimCompletion(outputId) {
|
|
36
|
+
const claimedAt = this.now().toISOString();
|
|
37
|
+
const claimed = this.database.sqlite.prepare(`update bash_output_streams
|
|
38
|
+
set completion_claimed_at = ?
|
|
39
|
+
where id = ? and returned = 1 and status != 'running' and completion_claimed_at is null`).run(claimedAt, outputId);
|
|
40
|
+
return claimed.changes === 1 ? this.read(outputId) : undefined;
|
|
41
|
+
}
|
|
42
|
+
finish(outputId, input) {
|
|
43
|
+
const status = input.error || input.timedOut || input.signal || (input.exitCode !== undefined && input.exitCode !== 0)
|
|
44
|
+
? "failed"
|
|
45
|
+
: "done";
|
|
46
|
+
this.database.sqlite.prepare(`update bash_output_streams
|
|
47
|
+
set status = ?, exit_code = ?, signal = ?, timed_out = ?, error = ?, finished_at = ?
|
|
48
|
+
where id = ?`).run(status, input.exitCode ?? null, input.signal ?? null, input.timedOut ? 1 : 0, input.error ?? null, this.now().toISOString(), outputId);
|
|
49
|
+
}
|
|
50
|
+
read(outputId) {
|
|
51
|
+
const stream = this.database.sqlite.prepare("select * from bash_output_streams where id = ?").get(outputId);
|
|
52
|
+
if (!stream)
|
|
53
|
+
return undefined;
|
|
54
|
+
const rows = this.database.sqlite.prepare(`select * from bash_output_chunks
|
|
55
|
+
where output_id = ?
|
|
56
|
+
order by sequence asc`).all(outputId);
|
|
57
|
+
const decoded = decodeChunks(rows);
|
|
58
|
+
return {
|
|
59
|
+
outputId: stream.id,
|
|
60
|
+
activityId: stream.activity_id,
|
|
61
|
+
turnId: stream.turn_id,
|
|
62
|
+
...(stream.conversation_scope_id ? { conversationScopeId: stream.conversation_scope_id } : {}),
|
|
63
|
+
processId: stream.process_id,
|
|
64
|
+
workspaceId: stream.workspace_id,
|
|
65
|
+
workspaceRoot: stream.workspace_root,
|
|
66
|
+
command: stream.command,
|
|
67
|
+
tty: stream.tty === 1,
|
|
68
|
+
output: decoded.map((chunk) => chunk.data).join(""),
|
|
69
|
+
chunks: decoded,
|
|
70
|
+
status: isBashOutputStatus(stream.status) ? stream.status : "failed",
|
|
71
|
+
...(stream.exit_code !== null ? { exitCode: stream.exit_code } : {}),
|
|
72
|
+
...(stream.signal ? { signal: stream.signal } : {}),
|
|
73
|
+
timedOut: stream.timed_out === 1,
|
|
74
|
+
...(stream.error ? { error: stream.error } : {}),
|
|
75
|
+
returned: stream.returned === 1,
|
|
76
|
+
startedAt: stream.started_at,
|
|
77
|
+
...(stream.finished_at ? { finishedAt: stream.finished_at } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
close() {
|
|
81
|
+
this.database.close();
|
|
82
|
+
}
|
|
83
|
+
nextSequence(outputId) {
|
|
84
|
+
const known = this.nextSequences.get(outputId);
|
|
85
|
+
if (known !== undefined) {
|
|
86
|
+
this.nextSequences.set(outputId, known + 1);
|
|
87
|
+
return known;
|
|
88
|
+
}
|
|
89
|
+
const row = this.database.sqlite.prepare("select coalesce(max(sequence), 0) as sequence from bash_output_chunks where output_id = ?").get(outputId);
|
|
90
|
+
const sequence = row.sequence + 1;
|
|
91
|
+
this.nextSequences.set(outputId, sequence + 1);
|
|
92
|
+
return sequence;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function decodeChunks(rows) {
|
|
96
|
+
const decoders = new Map();
|
|
97
|
+
const chunks = [];
|
|
98
|
+
const lastChunkIndex = new Map();
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
if (!isBashOutputChannel(row.channel)) {
|
|
101
|
+
throw new Error(`Unknown Bash output channel: ${row.channel}`);
|
|
102
|
+
}
|
|
103
|
+
const decoder = decoders.get(row.channel) ?? new StringDecoder("utf8");
|
|
104
|
+
decoders.set(row.channel, decoder);
|
|
105
|
+
const data = decoder.write(row.data);
|
|
106
|
+
chunks.push({ sequence: row.sequence, channel: row.channel, data });
|
|
107
|
+
lastChunkIndex.set(row.channel, chunks.length - 1);
|
|
108
|
+
}
|
|
109
|
+
for (const [channel, decoder] of decoders) {
|
|
110
|
+
const tail = decoder.end();
|
|
111
|
+
if (!tail)
|
|
112
|
+
continue;
|
|
113
|
+
const index = lastChunkIndex.get(channel);
|
|
114
|
+
if (index === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
const chunk = chunks[index];
|
|
117
|
+
if (chunk)
|
|
118
|
+
chunk.data += tail;
|
|
119
|
+
}
|
|
120
|
+
return chunks;
|
|
121
|
+
}
|
|
122
|
+
function isBashOutputChannel(value) {
|
|
123
|
+
return value === "stdout" || value === "stderr" || value === "pty" || value === "process";
|
|
124
|
+
}
|
|
125
|
+
function isBashOutputStatus(value) {
|
|
126
|
+
return value === "running" || value === "done" || value === "failed";
|
|
127
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { HookExecutionError } from "../hooks.js";
|
|
3
|
+
export class ActivityLifecycle {
|
|
4
|
+
auditStore;
|
|
5
|
+
activityId;
|
|
6
|
+
turnId;
|
|
7
|
+
constructor(auditStore, options = {}) {
|
|
8
|
+
this.auditStore = auditStore;
|
|
9
|
+
this.activityId = options.activityId ?? newActivityId;
|
|
10
|
+
this.turnId = options.turnId ?? newTurnId;
|
|
11
|
+
}
|
|
12
|
+
record(options) {
|
|
13
|
+
const context = this.start(options);
|
|
14
|
+
this.finish(context.activityId, options.result, options.outcome);
|
|
15
|
+
return context;
|
|
16
|
+
}
|
|
17
|
+
recordLinked(options) {
|
|
18
|
+
const source = this.auditStore.getActivity(options.sourceActivityId);
|
|
19
|
+
if (!source)
|
|
20
|
+
throw new Error(`Unknown source Activity: ${options.sourceActivityId}`);
|
|
21
|
+
const { sourceActivityId: _sourceActivityId, ...record } = options;
|
|
22
|
+
return this.record({
|
|
23
|
+
...record,
|
|
24
|
+
...(source.conversationScopeId ? { conversationScopeId: source.conversationScopeId } : {}),
|
|
25
|
+
workspace: source.workspace,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async run(options) {
|
|
29
|
+
const executionContext = this.start(options);
|
|
30
|
+
const activityId = executionContext.activityId;
|
|
31
|
+
try {
|
|
32
|
+
const result = await options.operation(executionContext);
|
|
33
|
+
this.finish(activityId, result, options.outcome?.(result) ?? { type: "succeeded" });
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
this.auditStore.append(error instanceof HookExecutionError && error.event === "BeforeTool"
|
|
39
|
+
? { type: "blocked", activityId, error: message }
|
|
40
|
+
: { type: "failed", activityId, error: message });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
start(options) {
|
|
45
|
+
const activityId = options.activityId ?? this.activityId();
|
|
46
|
+
const turnId = options.turnId ?? this.turnId();
|
|
47
|
+
const request = normalizeAuditValue(options.request);
|
|
48
|
+
this.auditStore.append({
|
|
49
|
+
type: "started",
|
|
50
|
+
activityId,
|
|
51
|
+
turnId,
|
|
52
|
+
...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
|
|
53
|
+
tool: options.tool,
|
|
54
|
+
workspace: options.workspace,
|
|
55
|
+
...(request !== undefined ? { request } : {}),
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
activityId,
|
|
59
|
+
turnId,
|
|
60
|
+
...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
finish(activityId, result, outcome) {
|
|
64
|
+
const normalizedResult = normalizeAuditValue(result);
|
|
65
|
+
switch (outcome.type) {
|
|
66
|
+
case "succeeded":
|
|
67
|
+
case "returned":
|
|
68
|
+
this.auditStore.append({
|
|
69
|
+
type: outcome.type,
|
|
70
|
+
activityId,
|
|
71
|
+
...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
|
|
72
|
+
});
|
|
73
|
+
break;
|
|
74
|
+
case "failed":
|
|
75
|
+
this.auditStore.append({
|
|
76
|
+
type: "failed",
|
|
77
|
+
activityId,
|
|
78
|
+
...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
|
|
79
|
+
error: outcome.error,
|
|
80
|
+
});
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function newActivityId() {
|
|
86
|
+
return `act_${randomUUID().replaceAll("-", "")}`;
|
|
87
|
+
}
|
|
88
|
+
function newTurnId() {
|
|
89
|
+
return `turn_${randomUUID().replaceAll("-", "")}`;
|
|
90
|
+
}
|
|
91
|
+
export function normalizeAuditValue(value) {
|
|
92
|
+
return normalizeAuditValueInternal(value, new WeakSet());
|
|
93
|
+
}
|
|
94
|
+
function normalizeAuditValueInternal(value, seen) {
|
|
95
|
+
if (value === undefined || typeof value === "function" || typeof value === "symbol")
|
|
96
|
+
return undefined;
|
|
97
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
98
|
+
return value;
|
|
99
|
+
if (typeof value === "number")
|
|
100
|
+
return Number.isFinite(value) ? value : String(value);
|
|
101
|
+
if (typeof value === "bigint")
|
|
102
|
+
return value.toString();
|
|
103
|
+
if (value instanceof Date)
|
|
104
|
+
return value.toISOString();
|
|
105
|
+
if (value instanceof Error) {
|
|
106
|
+
return {
|
|
107
|
+
name: value.name,
|
|
108
|
+
message: value.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (value instanceof Uint8Array) {
|
|
112
|
+
return {
|
|
113
|
+
type: "bytes",
|
|
114
|
+
encoding: "base64",
|
|
115
|
+
data: Buffer.from(value).toString("base64"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(value)) {
|
|
119
|
+
return value.map((entry) => normalizeAuditValueInternal(entry, seen) ?? null);
|
|
120
|
+
}
|
|
121
|
+
if (typeof value !== "object")
|
|
122
|
+
return String(value);
|
|
123
|
+
if (seen.has(value))
|
|
124
|
+
return "[Circular]";
|
|
125
|
+
seen.add(value);
|
|
126
|
+
try {
|
|
127
|
+
const normalized = {};
|
|
128
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
129
|
+
const next = normalizeAuditValueInternal(entry, seen);
|
|
130
|
+
if (next !== undefined)
|
|
131
|
+
normalized[key] = next;
|
|
132
|
+
}
|
|
133
|
+
return normalized;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
seen.delete(value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/db/migrations.js
CHANGED
|
@@ -39,6 +39,11 @@ const migrations = [
|
|
|
39
39
|
name: "activity-audit",
|
|
40
40
|
up: migrateActivityAudit,
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
version: 9,
|
|
44
|
+
name: "bash-output-audit",
|
|
45
|
+
up: migrateBashOutputAudit,
|
|
46
|
+
},
|
|
42
47
|
];
|
|
43
48
|
export function migrateDatabase(sqlite) {
|
|
44
49
|
const migrate = sqlite.transaction(() => {
|
|
@@ -248,6 +253,49 @@ function migrateActivityAudit(sqlite) {
|
|
|
248
253
|
on activity_audit_events(created_at);
|
|
249
254
|
`);
|
|
250
255
|
}
|
|
256
|
+
function migrateBashOutputAudit(sqlite) {
|
|
257
|
+
sqlite.exec(`
|
|
258
|
+
create table if not exists bash_output_streams (
|
|
259
|
+
id text primary key,
|
|
260
|
+
activity_id text not null,
|
|
261
|
+
turn_id text not null,
|
|
262
|
+
conversation_scope_id text,
|
|
263
|
+
process_id integer not null,
|
|
264
|
+
workspace_id text not null,
|
|
265
|
+
workspace_root text not null,
|
|
266
|
+
command text not null,
|
|
267
|
+
tty integer not null default 0,
|
|
268
|
+
status text not null default 'running',
|
|
269
|
+
exit_code integer,
|
|
270
|
+
signal text,
|
|
271
|
+
timed_out integer not null default 0,
|
|
272
|
+
error text,
|
|
273
|
+
returned integer not null default 0,
|
|
274
|
+
completion_claimed_at text,
|
|
275
|
+
started_at text not null,
|
|
276
|
+
finished_at text
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
create index if not exists bash_output_streams_activity_idx
|
|
280
|
+
on bash_output_streams(activity_id);
|
|
281
|
+
|
|
282
|
+
create index if not exists bash_output_streams_workspace_idx
|
|
283
|
+
on bash_output_streams(workspace_id, started_at);
|
|
284
|
+
|
|
285
|
+
create table if not exists bash_output_chunks (
|
|
286
|
+
output_id text not null,
|
|
287
|
+
sequence integer not null,
|
|
288
|
+
channel text not null,
|
|
289
|
+
data blob not null,
|
|
290
|
+
created_at text not null,
|
|
291
|
+
primary key (output_id, sequence),
|
|
292
|
+
foreign key (output_id) references bash_output_streams(id) on delete cascade
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
create index if not exists bash_output_chunks_output_idx
|
|
296
|
+
on bash_output_chunks(output_id, sequence);
|
|
297
|
+
`);
|
|
298
|
+
}
|
|
251
299
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
252
300
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
253
301
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { blob, index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
2
2
|
export const workspaceSessions = sqliteTable("workspace_sessions", {
|
|
3
3
|
id: text("id").primaryKey(),
|
|
4
4
|
root: text("root").notNull(),
|
|
@@ -97,6 +97,41 @@ export const activityAuditEvents = sqliteTable("activity_audit_events", {
|
|
|
97
97
|
index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
|
|
98
98
|
index("activity_audit_events_created_idx").on(table.createdAt),
|
|
99
99
|
]);
|
|
100
|
+
export const bashOutputStreams = sqliteTable("bash_output_streams", {
|
|
101
|
+
id: text("id").primaryKey(),
|
|
102
|
+
activityId: text("activity_id").notNull(),
|
|
103
|
+
turnId: text("turn_id").notNull(),
|
|
104
|
+
conversationScopeId: text("conversation_scope_id"),
|
|
105
|
+
processId: integer("process_id").notNull(),
|
|
106
|
+
workspaceId: text("workspace_id").notNull(),
|
|
107
|
+
workspaceRoot: text("workspace_root").notNull(),
|
|
108
|
+
command: text("command").notNull(),
|
|
109
|
+
tty: integer("tty", { mode: "boolean" }).notNull().default(false),
|
|
110
|
+
status: text("status").notNull().default("running"),
|
|
111
|
+
exitCode: integer("exit_code"),
|
|
112
|
+
signal: text("signal"),
|
|
113
|
+
timedOut: integer("timed_out", { mode: "boolean" }).notNull().default(false),
|
|
114
|
+
error: text("error"),
|
|
115
|
+
returned: integer("returned", { mode: "boolean" }).notNull().default(false),
|
|
116
|
+
completionClaimedAt: text("completion_claimed_at"),
|
|
117
|
+
startedAt: text("started_at").notNull(),
|
|
118
|
+
finishedAt: text("finished_at"),
|
|
119
|
+
}, (table) => [
|
|
120
|
+
index("bash_output_streams_activity_idx").on(table.activityId),
|
|
121
|
+
index("bash_output_streams_workspace_idx").on(table.workspaceId, table.startedAt),
|
|
122
|
+
]);
|
|
123
|
+
export const bashOutputChunks = sqliteTable("bash_output_chunks", {
|
|
124
|
+
outputId: text("output_id")
|
|
125
|
+
.notNull()
|
|
126
|
+
.references(() => bashOutputStreams.id, { onDelete: "cascade" }),
|
|
127
|
+
sequence: integer("sequence").notNull(),
|
|
128
|
+
channel: text("channel").notNull(),
|
|
129
|
+
data: blob("data", { mode: "buffer" }).notNull(),
|
|
130
|
+
createdAt: text("created_at").notNull(),
|
|
131
|
+
}, (table) => [
|
|
132
|
+
primaryKey({ columns: [table.outputId, table.sequence] }),
|
|
133
|
+
index("bash_output_chunks_output_idx").on(table.outputId, table.sequence),
|
|
134
|
+
]);
|
|
100
135
|
export const localAgentSessions = sqliteTable("local_agent_sessions", {
|
|
101
136
|
id: text("id").primaryKey(),
|
|
102
137
|
workspaceId: text("workspace_id"),
|
|
@@ -4,6 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
6
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
|
+
import { ActivityAuditStore } from "../../activity/audit-store.js";
|
|
8
|
+
import { BashOutputStore } from "../../activity/bash-output-store.js";
|
|
9
|
+
import { ActivityLifecycle } from "../../activity/lifecycle.js";
|
|
7
10
|
import { loadConfig } from "../../config.js";
|
|
8
11
|
import { createReviewCheckpointManager } from "../../review-checkpoints.js";
|
|
9
12
|
import { ProcessManager } from "../../process-sessions.js";
|
|
@@ -32,9 +35,12 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
32
35
|
});
|
|
33
36
|
const store = new SqliteWorkspaceStore(stateDir);
|
|
34
37
|
const workspaces = new WorkspaceRegistry(config, store);
|
|
35
|
-
const
|
|
38
|
+
const auditStore = new ActivityAuditStore(stateDir);
|
|
39
|
+
const bashOutputStore = new BashOutputStore(stateDir);
|
|
40
|
+
const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
|
|
41
|
+
const activityLifecycle = new ActivityLifecycle(auditStore);
|
|
36
42
|
const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
|
|
37
|
-
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence);
|
|
43
|
+
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
|
|
38
44
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
39
45
|
const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
|
|
40
46
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
@@ -47,6 +53,8 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
47
53
|
await server.close();
|
|
48
54
|
await codeIntelligence.shutdown();
|
|
49
55
|
processSessions.shutdown();
|
|
56
|
+
bashOutputStore.close();
|
|
57
|
+
auditStore.close();
|
|
50
58
|
store.close();
|
|
51
59
|
};
|
|
52
60
|
t.after(async () => {
|
package/dist/process-sessions.js
CHANGED
|
@@ -209,6 +209,7 @@ export class ProcessManager {
|
|
|
209
209
|
completedProcessTtlMs;
|
|
210
210
|
maxStartYieldMs;
|
|
211
211
|
monotonicNow;
|
|
212
|
+
outputAudit;
|
|
212
213
|
nextProcessId = 1;
|
|
213
214
|
constructor(options = {}) {
|
|
214
215
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
@@ -225,6 +226,7 @@ export class ProcessManager {
|
|
|
225
226
|
?? COMPLETED_PROCESS_TTL_MS;
|
|
226
227
|
this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
|
|
227
228
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
229
|
+
this.outputAudit = options.outputAudit;
|
|
228
230
|
}
|
|
229
231
|
async start(input) {
|
|
230
232
|
input.signal?.throwIfAborted();
|
|
@@ -241,6 +243,10 @@ export class ProcessManager {
|
|
|
241
243
|
this.startPipe(processEntry, input);
|
|
242
244
|
}
|
|
243
245
|
catch (error) {
|
|
246
|
+
this.finishAudit(processEntry, {
|
|
247
|
+
timedOut: false,
|
|
248
|
+
error: error instanceof Error ? error.message : String(error),
|
|
249
|
+
});
|
|
244
250
|
this.processes.delete(processEntry.id);
|
|
245
251
|
throw error;
|
|
246
252
|
}
|
|
@@ -349,8 +355,14 @@ export class ProcessManager {
|
|
|
349
355
|
shutdown() {
|
|
350
356
|
for (const processEntry of this.processes.values()) {
|
|
351
357
|
this.clearProcessTimers(processEntry);
|
|
352
|
-
if (processEntry.running)
|
|
358
|
+
if (processEntry.running) {
|
|
359
|
+
this.finishAudit(processEntry, {
|
|
360
|
+
signal: "shutdown",
|
|
361
|
+
timedOut: false,
|
|
362
|
+
error: "ForgeRelay shut down while the process was still running.",
|
|
363
|
+
});
|
|
353
364
|
processEntry.process?.kill("SIGTERM");
|
|
365
|
+
}
|
|
354
366
|
}
|
|
355
367
|
this.processes.clear();
|
|
356
368
|
this.completedByWorkspace.clear();
|
|
@@ -389,10 +401,25 @@ export class ProcessManager {
|
|
|
389
401
|
const exitPromise = new Promise((resolve) => {
|
|
390
402
|
resolveExit = resolve;
|
|
391
403
|
});
|
|
404
|
+
const id = this.nextProcessId++;
|
|
405
|
+
const outputId = this.outputAudit && input.audit
|
|
406
|
+
? this.outputAudit.begin({
|
|
407
|
+
activityId: input.audit.activityId,
|
|
408
|
+
turnId: input.audit.turnId,
|
|
409
|
+
...(input.audit.conversationScopeId ? { conversationScopeId: input.audit.conversationScopeId } : {}),
|
|
410
|
+
processId: id,
|
|
411
|
+
workspaceId: input.workspaceId,
|
|
412
|
+
workspaceRoot: input.workspaceRoot ?? input.cwd,
|
|
413
|
+
command: input.command,
|
|
414
|
+
tty: input.tty === true,
|
|
415
|
+
})
|
|
416
|
+
: undefined;
|
|
392
417
|
return {
|
|
393
|
-
id
|
|
418
|
+
id,
|
|
394
419
|
workspaceId: input.workspaceId,
|
|
395
420
|
command: input.command,
|
|
421
|
+
...(outputId ? { outputId } : {}),
|
|
422
|
+
auditFinished: false,
|
|
396
423
|
startedAtMonotonic: this.monotonicNow(),
|
|
397
424
|
columns: terminalSize(input.columns, DEFAULT_COLUMNS),
|
|
398
425
|
rows: terminalSize(input.rows, DEFAULT_ROWS),
|
|
@@ -426,9 +453,9 @@ export class ProcessManager {
|
|
|
426
453
|
kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached),
|
|
427
454
|
resize: input.tty ? () => undefined : undefined,
|
|
428
455
|
};
|
|
429
|
-
child.stdout.on("data", (data) => this.append(processEntry,
|
|
430
|
-
child.stderr.on("data", (data) => this.append(processEntry,
|
|
431
|
-
child.on("error", (error) => this.append(processEntry, `${error.message}\n`));
|
|
456
|
+
child.stdout.on("data", (data) => this.append(processEntry, "stdout", data));
|
|
457
|
+
child.stderr.on("data", (data) => this.append(processEntry, "stderr", data));
|
|
458
|
+
child.on("error", (error) => this.append(processEntry, "process", `${error.message}\n`));
|
|
432
459
|
child.on("close", (code, signal) => this.finish(processEntry, code ?? undefined, signal ?? undefined));
|
|
433
460
|
}
|
|
434
461
|
async startPty(processEntry, input) {
|
|
@@ -462,7 +489,7 @@ export class ProcessManager {
|
|
|
462
489
|
kill: (signal) => pty.kill(signal),
|
|
463
490
|
resize: (columns, rows) => pty.resize(columns, rows),
|
|
464
491
|
};
|
|
465
|
-
pty.onData((data) => this.append(processEntry, data));
|
|
492
|
+
pty.onData((data) => this.append(processEntry, "pty", data));
|
|
466
493
|
pty.onExit(({ exitCode, signal }) => {
|
|
467
494
|
this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
|
|
468
495
|
});
|
|
@@ -475,6 +502,11 @@ export class ProcessManager {
|
|
|
475
502
|
processEntry.signal = signal;
|
|
476
503
|
processEntry.finishedAtMonotonic = this.monotonicNow();
|
|
477
504
|
processEntry.process = undefined;
|
|
505
|
+
this.finishAudit(processEntry, {
|
|
506
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
507
|
+
...(signal ? { signal } : {}),
|
|
508
|
+
timedOut: processEntry.timedOut,
|
|
509
|
+
});
|
|
478
510
|
if (processEntry.executionTimeoutTimer)
|
|
479
511
|
clearTimeout(processEntry.executionTimeoutTimer);
|
|
480
512
|
if (processEntry.forceKillTimer)
|
|
@@ -512,8 +544,17 @@ export class ProcessManager {
|
|
|
512
544
|
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), remainingMs);
|
|
513
545
|
processEntry.cleanupTimer.unref();
|
|
514
546
|
}
|
|
515
|
-
append(processEntry, output) {
|
|
516
|
-
processEntry.
|
|
547
|
+
append(processEntry, channel, output) {
|
|
548
|
+
if (processEntry.outputId && !processEntry.auditFinished) {
|
|
549
|
+
this.outputAudit?.append(processEntry.outputId, channel, output);
|
|
550
|
+
}
|
|
551
|
+
processEntry.buffer.append(typeof output === "string" ? output : Buffer.from(output).toString("utf8"));
|
|
552
|
+
}
|
|
553
|
+
finishAudit(processEntry, input) {
|
|
554
|
+
if (!processEntry.outputId || processEntry.auditFinished)
|
|
555
|
+
return;
|
|
556
|
+
processEntry.auditFinished = true;
|
|
557
|
+
this.outputAudit?.finish(processEntry.outputId, input);
|
|
517
558
|
}
|
|
518
559
|
consume(processEntry, maxOutputTokens) {
|
|
519
560
|
const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
|
|
@@ -526,6 +567,7 @@ export class ProcessManager {
|
|
|
526
567
|
return {
|
|
527
568
|
processId,
|
|
528
569
|
sessionId: processId,
|
|
570
|
+
...(processEntry.outputId ? { outputId: processEntry.outputId } : {}),
|
|
529
571
|
output: buffered.output,
|
|
530
572
|
outputTruncated: processEntry.outputWasTruncated,
|
|
531
573
|
running: processEntry.running,
|