@akira-tl/forgerelay 0.5.2 → 0.5.4
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 +18 -0
- package/dist/activity/audit-store.js +18 -0
- package/dist/activity/host-turn-store.js +46 -0
- package/dist/activity/lifecycle.js +5 -1
- package/dist/activity/mcp-query-tools.js +129 -0
- package/dist/activity/query-service.js +247 -0
- package/dist/db/migrations.js +20 -0
- package/dist/db/schema.js +8 -0
- package/dist/lsp/test-support/server-fixture.js +9 -2
- package/dist/server.js +12 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.4] - 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Closed all Activity query SQLite handles before temporary-state cleanup so the backend acceptance suite also passes on Windows, where open database files cannot be unlinked.
|
|
12
|
+
|
|
13
|
+
## [0.5.3] - 2026-08-15
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added durable Host Turn records and a stable Activity query projection backed by ForgeRelay's local SQLite audit history, with revision-based lightweight snapshots that remain queryable after ForgeRelay restarts.
|
|
18
|
+
- Added the production backend query contract for future MCP App UI: model-visible `activity_panel` establishes a Host Turn, while app-only `activity_snapshot`, `activity_detail`, and `activity_output` data sources expose summaries, selected lazy detail, and complete Bash output separately.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Activity snapshots now use an explicit summary whitelist: read bodies, write/edit patches, full Bash commands/output, and capability-heavy payloads stay out of normal snapshots; rename/delete include complete path targets and are summary-complete without detail requests.
|
|
23
|
+
- Late Bash completion records use the current Host Turn for delivery while preserving the original Bash Activity's immutable returned history and Workspace audit snapshot.
|
|
24
|
+
|
|
7
25
|
## [0.5.2] - 2026-08-15
|
|
8
26
|
|
|
9
27
|
### Added
|
|
@@ -47,6 +47,24 @@ export class ActivityAuditStore {
|
|
|
47
47
|
listEvents(activityId) {
|
|
48
48
|
return this.readRows(activityId).map(rowToEvent);
|
|
49
49
|
}
|
|
50
|
+
listActivitiesByTurn(turnId) {
|
|
51
|
+
const rows = this.database.sqlite.prepare(`select activity_id from activity_audit_events
|
|
52
|
+
where event_type = 'started' and turn_id = ?
|
|
53
|
+
order by rowid asc`).all(turnId);
|
|
54
|
+
return rows.flatMap(({ activity_id }) => {
|
|
55
|
+
const activity = this.getActivity(activity_id);
|
|
56
|
+
return activity ? [activity] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
turnRevision(turnId) {
|
|
60
|
+
const row = this.database.sqlite.prepare(`select count(*) as revision
|
|
61
|
+
from activity_audit_events
|
|
62
|
+
where activity_id in (
|
|
63
|
+
select activity_id from activity_audit_events
|
|
64
|
+
where event_type = 'started' and turn_id = ?
|
|
65
|
+
)`).get(turnId);
|
|
66
|
+
return row.revision;
|
|
67
|
+
}
|
|
50
68
|
getActivity(activityId) {
|
|
51
69
|
const events = this.listEvents(activityId);
|
|
52
70
|
const started = events[0];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { openDatabase } from "../db/client.js";
|
|
3
|
+
export class HostTurnStore {
|
|
4
|
+
database;
|
|
5
|
+
now;
|
|
6
|
+
nextTurnId;
|
|
7
|
+
constructor(stateDir, options = {}) {
|
|
8
|
+
this.database = openDatabase(stateDir);
|
|
9
|
+
this.now = options.now ?? (() => new Date());
|
|
10
|
+
this.nextTurnId = options.turnId ?? (() => `turn_${randomUUID().replaceAll("-", "")}`);
|
|
11
|
+
}
|
|
12
|
+
begin(conversationScopeId) {
|
|
13
|
+
const turnId = this.nextTurnId();
|
|
14
|
+
const createdAt = this.now().toISOString();
|
|
15
|
+
this.database.sqlite.prepare(`insert into activity_host_turns (turn_id, conversation_scope_id, created_at)
|
|
16
|
+
values (?, ?, ?)`).run(turnId, conversationScopeId ?? null, createdAt);
|
|
17
|
+
return {
|
|
18
|
+
turnId,
|
|
19
|
+
...(conversationScopeId ? { conversationScopeId } : {}),
|
|
20
|
+
createdAt,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
get(turnId) {
|
|
24
|
+
const row = this.database.sqlite.prepare("select * from activity_host_turns where turn_id = ?").get(turnId);
|
|
25
|
+
return row ? rowToTurn(row) : undefined;
|
|
26
|
+
}
|
|
27
|
+
current(conversationScopeId) {
|
|
28
|
+
if (!conversationScopeId)
|
|
29
|
+
return undefined;
|
|
30
|
+
const row = this.database.sqlite.prepare(`select * from activity_host_turns
|
|
31
|
+
where conversation_scope_id = ?
|
|
32
|
+
order by rowid desc
|
|
33
|
+
limit 1`).get(conversationScopeId);
|
|
34
|
+
return row ? rowToTurn(row) : undefined;
|
|
35
|
+
}
|
|
36
|
+
close() {
|
|
37
|
+
this.database.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function rowToTurn(row) {
|
|
41
|
+
return {
|
|
42
|
+
turnId: row.turn_id,
|
|
43
|
+
...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -4,10 +4,12 @@ export class ActivityLifecycle {
|
|
|
4
4
|
auditStore;
|
|
5
5
|
activityId;
|
|
6
6
|
turnId;
|
|
7
|
+
turnIdForConversation;
|
|
7
8
|
constructor(auditStore, options = {}) {
|
|
8
9
|
this.auditStore = auditStore;
|
|
9
10
|
this.activityId = options.activityId ?? newActivityId;
|
|
10
11
|
this.turnId = options.turnId ?? newTurnId;
|
|
12
|
+
this.turnIdForConversation = options.turnIdForConversation;
|
|
11
13
|
}
|
|
12
14
|
record(options) {
|
|
13
15
|
const context = this.start(options);
|
|
@@ -43,7 +45,9 @@ export class ActivityLifecycle {
|
|
|
43
45
|
}
|
|
44
46
|
start(options) {
|
|
45
47
|
const activityId = options.activityId ?? this.activityId();
|
|
46
|
-
const turnId = options.turnId
|
|
48
|
+
const turnId = options.turnId
|
|
49
|
+
?? this.turnIdForConversation?.(options.conversationScopeId)
|
|
50
|
+
?? this.turnId();
|
|
47
51
|
const request = normalizeAuditValue(options.request);
|
|
48
52
|
this.auditStore.append({
|
|
49
53
|
type: "started",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { openAiConversationScopeId } from "../request-meta.js";
|
|
4
|
+
const READ_ONLY_ANNOTATIONS = {
|
|
5
|
+
readOnlyHint: true,
|
|
6
|
+
destructiveHint: false,
|
|
7
|
+
idempotentHint: true,
|
|
8
|
+
openWorldHint: false,
|
|
9
|
+
};
|
|
10
|
+
const activitySummarySchema = z.object({
|
|
11
|
+
activityId: z.string(),
|
|
12
|
+
tool: z.string(),
|
|
13
|
+
kind: z.string(),
|
|
14
|
+
status: z.enum(["working", "done", "error"]),
|
|
15
|
+
state: z.enum(["executing", "returned", "done", "failed", "blocked"]),
|
|
16
|
+
title: z.string(),
|
|
17
|
+
target: z.string(),
|
|
18
|
+
detailAvailable: z.boolean(),
|
|
19
|
+
workspaceId: z.string().optional(),
|
|
20
|
+
processId: z.number().int().positive().optional(),
|
|
21
|
+
outputId: z.string().optional(),
|
|
22
|
+
commandLength: z.number().int().nonnegative().optional(),
|
|
23
|
+
bashPhase: z.enum(["executing", "returned", "done", "error"]).optional(),
|
|
24
|
+
startedAt: z.string(),
|
|
25
|
+
finishedAt: z.string().optional(),
|
|
26
|
+
durationMs: z.number().nonnegative().optional(),
|
|
27
|
+
});
|
|
28
|
+
const snapshotOutputSchema = {
|
|
29
|
+
turnId: z.string(),
|
|
30
|
+
revision: z.number().int().nonnegative(),
|
|
31
|
+
changed: z.boolean(),
|
|
32
|
+
state: z.enum(["working", "done", "error"]),
|
|
33
|
+
activities: z.array(activitySummarySchema),
|
|
34
|
+
};
|
|
35
|
+
export function registerActivityQueryTools(server, queries) {
|
|
36
|
+
registerAppTool(server, "activity_panel", {
|
|
37
|
+
title: "Begin Activity Panel",
|
|
38
|
+
description: "Begin one ForgeRelay Host Turn lifecycle for subsequent project work. This orchestration call does not read or modify project files. Call it once before the first ForgeRelay work operation in a Host Turn that performs project work.",
|
|
39
|
+
inputSchema: {},
|
|
40
|
+
outputSchema: snapshotOutputSchema,
|
|
41
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
42
|
+
annotations: {
|
|
43
|
+
...READ_ONLY_ANNOTATIONS,
|
|
44
|
+
idempotentHint: false,
|
|
45
|
+
},
|
|
46
|
+
}, async (_input, extra) => {
|
|
47
|
+
const snapshot = queries.beginTurn(openAiConversationScopeId(extra._meta));
|
|
48
|
+
return {
|
|
49
|
+
content: [{
|
|
50
|
+
type: "text",
|
|
51
|
+
text: `Started ForgeRelay Activity Panel Host Turn ${snapshot.turnId}.`,
|
|
52
|
+
}],
|
|
53
|
+
structuredContent: { ...snapshot },
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
registerAppTool(server, "activity_snapshot", {
|
|
57
|
+
title: "Read Activity snapshot",
|
|
58
|
+
description: "App-only data source for lightweight Activity summaries in one durable Host Turn.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
turnId: z.string(),
|
|
61
|
+
knownRevision: z.number().int().nonnegative().optional(),
|
|
62
|
+
},
|
|
63
|
+
outputSchema: snapshotOutputSchema,
|
|
64
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
65
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
66
|
+
}, async ({ turnId, knownRevision }) => {
|
|
67
|
+
const snapshot = queries.snapshot(turnId, knownRevision);
|
|
68
|
+
return {
|
|
69
|
+
content: [{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: snapshot.changed
|
|
72
|
+
? `Activity snapshot ${turnId} revision ${snapshot.revision}.`
|
|
73
|
+
: `Activity snapshot ${turnId} unchanged at revision ${snapshot.revision}.`,
|
|
74
|
+
}],
|
|
75
|
+
structuredContent: { ...snapshot },
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
registerAppTool(server, "activity_detail", {
|
|
79
|
+
title: "Read Activity detail",
|
|
80
|
+
description: "App-only lazy data source for one selected expandable Activity.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
turnId: z.string(),
|
|
83
|
+
activityId: z.string(),
|
|
84
|
+
},
|
|
85
|
+
outputSchema: {
|
|
86
|
+
activity: activitySummarySchema,
|
|
87
|
+
request: z.unknown().optional(),
|
|
88
|
+
result: z.unknown().optional(),
|
|
89
|
+
error: z.string().optional(),
|
|
90
|
+
},
|
|
91
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
92
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
93
|
+
}, async ({ turnId, activityId }) => {
|
|
94
|
+
const detail = queries.detail(turnId, activityId);
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: `Activity detail ${activityId}.` }],
|
|
97
|
+
structuredContent: { ...detail },
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
registerAppTool(server, "activity_output", {
|
|
101
|
+
title: "Read Bash output",
|
|
102
|
+
description: "App-only lazy data source for complete durable Bash command/output by stable outputId.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
turnId: z.string(),
|
|
105
|
+
outputId: z.string(),
|
|
106
|
+
},
|
|
107
|
+
outputSchema: {
|
|
108
|
+
outputId: z.string(),
|
|
109
|
+
activityId: z.string(),
|
|
110
|
+
processId: z.number().int().positive(),
|
|
111
|
+
command: z.string(),
|
|
112
|
+
output: z.string(),
|
|
113
|
+
status: z.enum(["running", "done", "failed"]),
|
|
114
|
+
exitCode: z.number().int().optional(),
|
|
115
|
+
signal: z.string().optional(),
|
|
116
|
+
timedOut: z.boolean(),
|
|
117
|
+
startedAt: z.string(),
|
|
118
|
+
finishedAt: z.string().optional(),
|
|
119
|
+
},
|
|
120
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
121
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
122
|
+
}, async ({ turnId, outputId }) => {
|
|
123
|
+
const output = queries.bashOutput(turnId, outputId);
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: "text", text: `Bash output ${outputId}.` }],
|
|
126
|
+
structuredContent: { ...output },
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
export class ActivityQueryService {
|
|
2
|
+
turns;
|
|
3
|
+
audit;
|
|
4
|
+
outputs;
|
|
5
|
+
constructor(turns, audit, outputs) {
|
|
6
|
+
this.turns = turns;
|
|
7
|
+
this.audit = audit;
|
|
8
|
+
this.outputs = outputs;
|
|
9
|
+
}
|
|
10
|
+
beginTurn(conversationScopeId) {
|
|
11
|
+
const turn = this.turns.begin(conversationScopeId);
|
|
12
|
+
return this.snapshot(turn.turnId);
|
|
13
|
+
}
|
|
14
|
+
currentTurnId(conversationScopeId) {
|
|
15
|
+
return this.turns.current(conversationScopeId)?.turnId;
|
|
16
|
+
}
|
|
17
|
+
snapshot(turnId, knownRevision) {
|
|
18
|
+
this.requireTurn(turnId);
|
|
19
|
+
const revision = this.audit.turnRevision(turnId);
|
|
20
|
+
const activities = this.audit.listActivitiesByTurn(turnId).map(toSummary);
|
|
21
|
+
const state = aggregateState(activities);
|
|
22
|
+
const changed = knownRevision === undefined || knownRevision !== revision;
|
|
23
|
+
return {
|
|
24
|
+
turnId,
|
|
25
|
+
revision,
|
|
26
|
+
changed,
|
|
27
|
+
state,
|
|
28
|
+
activities: changed ? activities : [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
detail(turnId, activityId) {
|
|
32
|
+
this.requireTurn(turnId);
|
|
33
|
+
const record = this.audit.getActivity(activityId);
|
|
34
|
+
if (!record || record.turnId !== turnId) {
|
|
35
|
+
throw new Error(`Unknown Activity ${activityId} in Host Turn ${turnId}.`);
|
|
36
|
+
}
|
|
37
|
+
const activity = toSummary(record);
|
|
38
|
+
if (!activity.detailAvailable) {
|
|
39
|
+
throw new Error(`Activity ${activityId} is summary-complete and has no lazy detail.`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
activity,
|
|
43
|
+
...(record.request !== undefined ? { request: record.request } : {}),
|
|
44
|
+
...(record.result !== undefined ? { result: record.result } : {}),
|
|
45
|
+
...(record.error !== undefined ? { error: record.error } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
bashOutput(turnId, outputId) {
|
|
49
|
+
this.requireTurn(turnId);
|
|
50
|
+
const output = this.outputs.read(outputId);
|
|
51
|
+
if (!output)
|
|
52
|
+
throw new Error(`Unknown Bash output: ${outputId}.`);
|
|
53
|
+
const activities = this.audit.listActivitiesByTurn(turnId);
|
|
54
|
+
const visible = activities.some((activity) => activity.activityId === output.activityId || activityOutputId(activity) === outputId);
|
|
55
|
+
if (!visible) {
|
|
56
|
+
throw new Error(`Bash output ${outputId} is not part of Host Turn ${turnId}.`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
outputId: output.outputId,
|
|
60
|
+
activityId: output.activityId,
|
|
61
|
+
processId: output.processId,
|
|
62
|
+
command: output.command,
|
|
63
|
+
output: output.output,
|
|
64
|
+
status: output.status,
|
|
65
|
+
...(output.exitCode !== undefined ? { exitCode: output.exitCode } : {}),
|
|
66
|
+
...(output.signal !== undefined ? { signal: output.signal } : {}),
|
|
67
|
+
timedOut: output.timedOut,
|
|
68
|
+
startedAt: output.startedAt,
|
|
69
|
+
...(output.finishedAt !== undefined ? { finishedAt: output.finishedAt } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
requireTurn(turnId) {
|
|
73
|
+
if (!this.turns.get(turnId))
|
|
74
|
+
throw new Error(`Unknown Host Turn: ${turnId}.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function toSummary(record) {
|
|
78
|
+
const request = asRecord(record.request);
|
|
79
|
+
const result = asRecord(record.result);
|
|
80
|
+
const structured = asRecord(result?.structuredContent);
|
|
81
|
+
const directProcessId = numberField(result, "processId") ?? numberField(request, "processId");
|
|
82
|
+
const processId = numberField(structured, "processId") ?? directProcessId;
|
|
83
|
+
const outputId = stringField(structured, "outputId")
|
|
84
|
+
?? stringField(result, "outputId")
|
|
85
|
+
?? stringField(request, "outputId");
|
|
86
|
+
const command = stringField(request, "command") ?? stringField(request, "cmd");
|
|
87
|
+
const durationMs = numberField(structured, "wallTimeMs")
|
|
88
|
+
?? numberField(result, "wallTimeMs")
|
|
89
|
+
?? elapsedMs(record.startedAt, record.updatedAt, record.state);
|
|
90
|
+
const bashLike = record.tool === "bash" || record.tool === "exec_command" || record.tool === "bash_result";
|
|
91
|
+
return {
|
|
92
|
+
activityId: record.activityId,
|
|
93
|
+
tool: record.tool,
|
|
94
|
+
kind: activityKind(record.tool),
|
|
95
|
+
status: activityStatus(record.state),
|
|
96
|
+
state: record.state,
|
|
97
|
+
title: activityTitle(record.tool),
|
|
98
|
+
target: activityTarget(record, request, result, structured),
|
|
99
|
+
detailAvailable: record.tool !== "rename" && record.tool !== "delete",
|
|
100
|
+
...(record.workspace.id ? { workspaceId: record.workspace.id } : {}),
|
|
101
|
+
...(processId !== undefined ? { processId } : {}),
|
|
102
|
+
...(outputId !== undefined ? { outputId } : {}),
|
|
103
|
+
...(bashLike && command !== undefined ? { commandLength: command.length } : {}),
|
|
104
|
+
...(bashLike ? { bashPhase: bashPhase(record.state) } : {}),
|
|
105
|
+
startedAt: record.startedAt,
|
|
106
|
+
...(record.state !== "executing" ? { finishedAt: record.updatedAt } : {}),
|
|
107
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function activityKind(tool) {
|
|
111
|
+
if (tool === "read")
|
|
112
|
+
return "read";
|
|
113
|
+
if (tool === "write")
|
|
114
|
+
return "write";
|
|
115
|
+
if (tool === "edit" || tool === "apply_patch")
|
|
116
|
+
return "edit";
|
|
117
|
+
if (tool === "rename")
|
|
118
|
+
return "rename";
|
|
119
|
+
if (tool === "delete")
|
|
120
|
+
return "delete";
|
|
121
|
+
if (tool === "bash_result")
|
|
122
|
+
return "shell-result";
|
|
123
|
+
if (tool === "bash" || tool === "exec_command")
|
|
124
|
+
return "shell";
|
|
125
|
+
if (tool === "capability")
|
|
126
|
+
return "capability";
|
|
127
|
+
return "tool";
|
|
128
|
+
}
|
|
129
|
+
function activityTitle(tool) {
|
|
130
|
+
const titles = {
|
|
131
|
+
read: "Read",
|
|
132
|
+
write: "Write",
|
|
133
|
+
edit: "Edit",
|
|
134
|
+
apply_patch: "Edit",
|
|
135
|
+
rename: "Rename",
|
|
136
|
+
delete: "Delete",
|
|
137
|
+
bash: "Bash",
|
|
138
|
+
exec_command: "Command",
|
|
139
|
+
bash_result: "Bash result",
|
|
140
|
+
capability: "Capability",
|
|
141
|
+
};
|
|
142
|
+
return titles[tool] ?? tool;
|
|
143
|
+
}
|
|
144
|
+
function activityTarget(record, request, result, structured) {
|
|
145
|
+
if (record.tool === "bash" || record.tool === "exec_command")
|
|
146
|
+
return "Shell command";
|
|
147
|
+
if (record.tool === "bash_result") {
|
|
148
|
+
const processId = numberField(result, "processId") ?? numberField(request, "processId");
|
|
149
|
+
const exitCode = numberField(result, "exitCode");
|
|
150
|
+
const signal = stringField(result, "signal");
|
|
151
|
+
const timedOut = booleanField(result, "timedOut");
|
|
152
|
+
const outcome = timedOut
|
|
153
|
+
? "timed out"
|
|
154
|
+
: signal
|
|
155
|
+
? `signal ${signal}`
|
|
156
|
+
: exitCode !== undefined
|
|
157
|
+
? `exit ${exitCode}`
|
|
158
|
+
: record.state === "failed"
|
|
159
|
+
? "failed"
|
|
160
|
+
: "completed";
|
|
161
|
+
return `Process ${processId ?? "?"} · ${outcome}`;
|
|
162
|
+
}
|
|
163
|
+
if (record.tool === "capability") {
|
|
164
|
+
const name = stringField(request, "name") ?? "capability";
|
|
165
|
+
const action = stringField(request, "action") ?? "run";
|
|
166
|
+
return `${name} · ${action}`;
|
|
167
|
+
}
|
|
168
|
+
if (record.tool === "rename") {
|
|
169
|
+
const from = stringField(request, "path")
|
|
170
|
+
?? stringField(request, "from")
|
|
171
|
+
?? stringField(request, "source");
|
|
172
|
+
const to = stringField(request, "newPath")
|
|
173
|
+
?? stringField(request, "to")
|
|
174
|
+
?? stringField(request, "destination");
|
|
175
|
+
return [from, to].filter((value) => Boolean(value)).join(" → ") || "path";
|
|
176
|
+
}
|
|
177
|
+
const path = stringField(request, "path");
|
|
178
|
+
if (path)
|
|
179
|
+
return path;
|
|
180
|
+
if (record.tool === "apply_patch") {
|
|
181
|
+
const files = arrayField(structured, "files");
|
|
182
|
+
const first = asRecord(files?.[0]);
|
|
183
|
+
const firstPath = stringField(first, "path");
|
|
184
|
+
if (firstPath)
|
|
185
|
+
return files && files.length > 1 ? `${firstPath} +${files.length - 1}` : firstPath;
|
|
186
|
+
}
|
|
187
|
+
return record.tool;
|
|
188
|
+
}
|
|
189
|
+
function activityStatus(state) {
|
|
190
|
+
if (state === "executing")
|
|
191
|
+
return "working";
|
|
192
|
+
if (state === "failed" || state === "blocked")
|
|
193
|
+
return "error";
|
|
194
|
+
return "done";
|
|
195
|
+
}
|
|
196
|
+
function bashPhase(state) {
|
|
197
|
+
if (state === "executing")
|
|
198
|
+
return "executing";
|
|
199
|
+
if (state === "returned")
|
|
200
|
+
return "returned";
|
|
201
|
+
if (state === "failed" || state === "blocked")
|
|
202
|
+
return "error";
|
|
203
|
+
return "done";
|
|
204
|
+
}
|
|
205
|
+
function aggregateState(activities) {
|
|
206
|
+
if (activities.length === 0)
|
|
207
|
+
return "working";
|
|
208
|
+
if (activities.some((activity) => activity.status === "working"))
|
|
209
|
+
return "working";
|
|
210
|
+
if (activities.some((activity) => activity.status === "error"))
|
|
211
|
+
return "error";
|
|
212
|
+
return "done";
|
|
213
|
+
}
|
|
214
|
+
function activityOutputId(activity) {
|
|
215
|
+
const request = asRecord(activity.request);
|
|
216
|
+
const result = asRecord(activity.result);
|
|
217
|
+
const structured = asRecord(result?.structuredContent);
|
|
218
|
+
return stringField(request, "outputId")
|
|
219
|
+
?? stringField(result, "outputId")
|
|
220
|
+
?? stringField(structured, "outputId");
|
|
221
|
+
}
|
|
222
|
+
function asRecord(value) {
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
224
|
+
? value
|
|
225
|
+
: undefined;
|
|
226
|
+
}
|
|
227
|
+
function stringField(record, key) {
|
|
228
|
+
return typeof record?.[key] === "string" ? record[key] : undefined;
|
|
229
|
+
}
|
|
230
|
+
function numberField(record, key) {
|
|
231
|
+
return typeof record?.[key] === "number" ? record[key] : undefined;
|
|
232
|
+
}
|
|
233
|
+
function booleanField(record, key) {
|
|
234
|
+
return typeof record?.[key] === "boolean" ? record[key] : undefined;
|
|
235
|
+
}
|
|
236
|
+
function arrayField(record, key) {
|
|
237
|
+
return Array.isArray(record?.[key]) ? record[key] : undefined;
|
|
238
|
+
}
|
|
239
|
+
function elapsedMs(startedAt, updatedAt, state) {
|
|
240
|
+
if (state === "executing")
|
|
241
|
+
return undefined;
|
|
242
|
+
const start = Date.parse(startedAt);
|
|
243
|
+
const end = Date.parse(updatedAt);
|
|
244
|
+
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
245
|
+
return undefined;
|
|
246
|
+
return Math.max(0, end - start);
|
|
247
|
+
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -44,6 +44,11 @@ const migrations = [
|
|
|
44
44
|
name: "bash-output-audit",
|
|
45
45
|
up: migrateBashOutputAudit,
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
version: 10,
|
|
49
|
+
name: "activity-host-turns",
|
|
50
|
+
up: migrateActivityHostTurns,
|
|
51
|
+
},
|
|
47
52
|
];
|
|
48
53
|
export function migrateDatabase(sqlite) {
|
|
49
54
|
const migrate = sqlite.transaction(() => {
|
|
@@ -296,6 +301,21 @@ function migrateBashOutputAudit(sqlite) {
|
|
|
296
301
|
on bash_output_chunks(output_id, sequence);
|
|
297
302
|
`);
|
|
298
303
|
}
|
|
304
|
+
function migrateActivityHostTurns(sqlite) {
|
|
305
|
+
sqlite.exec(`
|
|
306
|
+
create table if not exists activity_host_turns (
|
|
307
|
+
turn_id text primary key,
|
|
308
|
+
conversation_scope_id text,
|
|
309
|
+
created_at text not null
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
create index if not exists activity_host_turns_conversation_idx
|
|
313
|
+
on activity_host_turns(conversation_scope_id, created_at desc);
|
|
314
|
+
|
|
315
|
+
create index if not exists activity_host_turns_created_idx
|
|
316
|
+
on activity_host_turns(created_at desc);
|
|
317
|
+
`);
|
|
318
|
+
}
|
|
299
319
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
300
320
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
301
321
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -97,6 +97,14 @@ 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 activityHostTurns = sqliteTable("activity_host_turns", {
|
|
101
|
+
turnId: text("turn_id").primaryKey(),
|
|
102
|
+
conversationScopeId: text("conversation_scope_id"),
|
|
103
|
+
createdAt: text("created_at").notNull(),
|
|
104
|
+
}, (table) => [
|
|
105
|
+
index("activity_host_turns_conversation_idx").on(table.conversationScopeId, table.createdAt),
|
|
106
|
+
index("activity_host_turns_created_idx").on(table.createdAt),
|
|
107
|
+
]);
|
|
100
108
|
export const bashOutputStreams = sqliteTable("bash_output_streams", {
|
|
101
109
|
id: text("id").primaryKey(),
|
|
102
110
|
activityId: text("activity_id").notNull(),
|
|
@@ -6,7 +6,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
6
6
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
7
7
|
import { ActivityAuditStore } from "../../activity/audit-store.js";
|
|
8
8
|
import { BashOutputStore } from "../../activity/bash-output-store.js";
|
|
9
|
+
import { HostTurnStore } from "../../activity/host-turn-store.js";
|
|
9
10
|
import { ActivityLifecycle } from "../../activity/lifecycle.js";
|
|
11
|
+
import { ActivityQueryService } from "../../activity/query-service.js";
|
|
10
12
|
import { loadConfig } from "../../config.js";
|
|
11
13
|
import { createReviewCheckpointManager } from "../../review-checkpoints.js";
|
|
12
14
|
import { ProcessManager } from "../../process-sessions.js";
|
|
@@ -37,10 +39,14 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
37
39
|
const workspaces = new WorkspaceRegistry(config, store);
|
|
38
40
|
const auditStore = new ActivityAuditStore(stateDir);
|
|
39
41
|
const bashOutputStore = new BashOutputStore(stateDir);
|
|
42
|
+
const hostTurnStore = new HostTurnStore(stateDir);
|
|
43
|
+
const activityQueries = new ActivityQueryService(hostTurnStore, auditStore, bashOutputStore);
|
|
40
44
|
const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
|
|
41
|
-
const activityLifecycle = new ActivityLifecycle(auditStore
|
|
45
|
+
const activityLifecycle = new ActivityLifecycle(auditStore, {
|
|
46
|
+
turnIdForConversation: (conversationScopeId) => activityQueries.currentTurnId(conversationScopeId),
|
|
47
|
+
});
|
|
42
48
|
const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
|
|
43
|
-
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
|
|
49
|
+
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
|
|
44
50
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
45
51
|
const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
|
|
46
52
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
@@ -53,6 +59,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
|
|
|
53
59
|
await server.close();
|
|
54
60
|
await codeIntelligence.shutdown();
|
|
55
61
|
processSessions.shutdown();
|
|
62
|
+
hostTurnStore.close();
|
|
56
63
|
bashOutputStore.close();
|
|
57
64
|
auditStore.close();
|
|
58
65
|
store.close();
|
package/dist/server.js
CHANGED
|
@@ -17,7 +17,10 @@ import * as z from "zod/v4";
|
|
|
17
17
|
import { applyPatch } from "./apply-patch.js";
|
|
18
18
|
import { ActivityAuditStore } from "./activity/audit-store.js";
|
|
19
19
|
import { BashOutputStore } from "./activity/bash-output-store.js";
|
|
20
|
+
import { HostTurnStore } from "./activity/host-turn-store.js";
|
|
21
|
+
import { registerActivityQueryTools } from "./activity/mcp-query-tools.js";
|
|
20
22
|
import { ActivityLifecycle, } from "./activity/lifecycle.js";
|
|
23
|
+
import { ActivityQueryService } from "./activity/query-service.js";
|
|
21
24
|
import { buildCapabilityFingerprint } from "./capabilities.js";
|
|
22
25
|
import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
|
|
23
26
|
import { deletePath, renamePath } from "./file-mutations.js";
|
|
@@ -992,7 +995,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
992
995
|
});
|
|
993
996
|
});
|
|
994
997
|
}
|
|
995
|
-
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore) {
|
|
998
|
+
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore, activityQueries) {
|
|
996
999
|
const toolDescriptions = buildToolDescriptions(config);
|
|
997
1000
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
|
|
998
1001
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
@@ -1465,6 +1468,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1465
1468
|
},
|
|
1466
1469
|
}, hookReports));
|
|
1467
1470
|
});
|
|
1471
|
+
registerActivityQueryTools(server, activityQueries);
|
|
1468
1472
|
registerAppTool(server, toolNames.capability, {
|
|
1469
1473
|
title: "Use optional capability",
|
|
1470
1474
|
description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
|
|
@@ -2453,8 +2457,12 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2453
2457
|
const workspaceStore = createWorkspaceStore(config.stateDir);
|
|
2454
2458
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
2455
2459
|
const activityAuditStore = new ActivityAuditStore(config.stateDir);
|
|
2456
|
-
const activityLifecycle = new ActivityLifecycle(activityAuditStore);
|
|
2457
2460
|
const bashOutputStore = new BashOutputStore(config.stateDir);
|
|
2461
|
+
const hostTurnStore = new HostTurnStore(config.stateDir);
|
|
2462
|
+
const activityQueries = new ActivityQueryService(hostTurnStore, activityAuditStore, bashOutputStore);
|
|
2463
|
+
const activityLifecycle = new ActivityLifecycle(activityAuditStore, {
|
|
2464
|
+
turnIdForConversation: (conversationScopeId) => activityQueries.currentTurnId(conversationScopeId),
|
|
2465
|
+
});
|
|
2458
2466
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
2459
2467
|
const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
|
|
2460
2468
|
const codeIntelligence = new CodeIntelligenceManager(config);
|
|
@@ -2641,7 +2649,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2641
2649
|
});
|
|
2642
2650
|
}
|
|
2643
2651
|
};
|
|
2644
|
-
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore);
|
|
2652
|
+
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
|
|
2645
2653
|
await server.connect(transport);
|
|
2646
2654
|
}
|
|
2647
2655
|
else {
|
|
@@ -2673,6 +2681,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2673
2681
|
processSessions.shutdown();
|
|
2674
2682
|
await codeIntelligence.shutdown();
|
|
2675
2683
|
oauthProvider.close();
|
|
2684
|
+
hostTurnStore.close();
|
|
2676
2685
|
bashOutputStore.close();
|
|
2677
2686
|
activityAuditStore.close();
|
|
2678
2687
|
workspaceStore.close?.();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"release:parity": "node scripts/release-parity.mjs",
|
|
45
45
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
46
46
|
"start": "node dist/cli.js serve",
|
|
47
|
-
"test": "node --test scripts/release-proof.test.mjs && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
47
|
+
"test": "node --test scripts/release-proof.test.mjs && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
48
48
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
49
|
"release:check": "node scripts/release-version.mjs check",
|
|
50
50
|
"release:tag-check": "node scripts/release-version.mjs tag",
|