@akira-tl/forgerelay 0.4.7 → 0.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/CHANGELOG.md +6 -0
- package/dist/activity/audit-store.js +209 -0
- package/dist/db/migrations.js +40 -0
- package/dist/db/schema.js +25 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-08-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added the production local Activity audit foundation: append-only Audit Events persist in ForgeRelay's existing SQLite state, queryable Activity Records survive server restarts and Workspace cleanup, and success, failure, and Hook-blocked outcomes retain immutable Workspace/Host Turn execution context for later lifecycle and UI releases.
|
|
12
|
+
|
|
7
13
|
## [0.4.7] - 2026-08-11
|
|
8
14
|
|
|
9
15
|
### Added
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { openDatabase } from "../db/client.js";
|
|
3
|
+
export class ActivityAuditStore {
|
|
4
|
+
database;
|
|
5
|
+
now;
|
|
6
|
+
constructor(stateDir, options = {}) {
|
|
7
|
+
this.database = openDatabase(stateDir);
|
|
8
|
+
this.now = options.now ?? (() => new Date());
|
|
9
|
+
}
|
|
10
|
+
append(input) {
|
|
11
|
+
return this.database.sqlite.transaction(() => {
|
|
12
|
+
const existing = this.readRows(input.activityId);
|
|
13
|
+
if (input.type === "started") {
|
|
14
|
+
if (existing.length > 0) {
|
|
15
|
+
throw new Error(`Activity ${input.activityId} already has audit events.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else if (existing.length === 0 || existing[0]?.event_type !== "started") {
|
|
19
|
+
throw new Error(`Activity ${input.activityId} must start before recording ${input.type}.`);
|
|
20
|
+
}
|
|
21
|
+
const sequence = existing.length + 1;
|
|
22
|
+
const id = `evt_${randomUUID().replaceAll("-", "")}`;
|
|
23
|
+
const createdAt = this.now().toISOString();
|
|
24
|
+
const row = eventInputToRow(input, { id, sequence, createdAt });
|
|
25
|
+
this.database.sqlite.prepare(`insert into activity_audit_events (
|
|
26
|
+
id,
|
|
27
|
+
activity_id,
|
|
28
|
+
sequence,
|
|
29
|
+
event_type,
|
|
30
|
+
turn_id,
|
|
31
|
+
conversation_scope_id,
|
|
32
|
+
tool,
|
|
33
|
+
workspace_id,
|
|
34
|
+
workspace_root,
|
|
35
|
+
workspace_mode,
|
|
36
|
+
workspace_source_root,
|
|
37
|
+
workspace_branch,
|
|
38
|
+
workspace_target_branch,
|
|
39
|
+
request_json,
|
|
40
|
+
result_json,
|
|
41
|
+
error,
|
|
42
|
+
created_at
|
|
43
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.activity_id, row.sequence, row.event_type, row.turn_id, row.conversation_scope_id, row.tool, row.workspace_id, row.workspace_root, row.workspace_mode, row.workspace_source_root, row.workspace_branch, row.workspace_target_branch, row.request_json, row.result_json, row.error, row.created_at);
|
|
44
|
+
return rowToEvent(row);
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
listEvents(activityId) {
|
|
48
|
+
return this.readRows(activityId).map(rowToEvent);
|
|
49
|
+
}
|
|
50
|
+
getActivity(activityId) {
|
|
51
|
+
const events = this.listEvents(activityId);
|
|
52
|
+
const started = events[0];
|
|
53
|
+
if (!started || started.type !== "started")
|
|
54
|
+
return undefined;
|
|
55
|
+
let state = "executing";
|
|
56
|
+
let result;
|
|
57
|
+
let error;
|
|
58
|
+
let updatedAt = started.createdAt;
|
|
59
|
+
for (const event of events.slice(1)) {
|
|
60
|
+
updatedAt = event.createdAt;
|
|
61
|
+
switch (event.type) {
|
|
62
|
+
case "started":
|
|
63
|
+
break;
|
|
64
|
+
case "succeeded":
|
|
65
|
+
state = "done";
|
|
66
|
+
result = event.result;
|
|
67
|
+
error = undefined;
|
|
68
|
+
break;
|
|
69
|
+
case "failed":
|
|
70
|
+
state = "failed";
|
|
71
|
+
result = event.result;
|
|
72
|
+
error = event.error;
|
|
73
|
+
break;
|
|
74
|
+
case "blocked":
|
|
75
|
+
state = "blocked";
|
|
76
|
+
result = undefined;
|
|
77
|
+
error = event.error;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
activityId: started.activityId,
|
|
83
|
+
turnId: started.turnId,
|
|
84
|
+
...(started.conversationScopeId ? { conversationScopeId: started.conversationScopeId } : {}),
|
|
85
|
+
tool: started.tool,
|
|
86
|
+
workspace: started.workspace,
|
|
87
|
+
state,
|
|
88
|
+
...(started.request !== undefined ? { request: started.request } : {}),
|
|
89
|
+
...(result !== undefined ? { result } : {}),
|
|
90
|
+
...(error !== undefined ? { error } : {}),
|
|
91
|
+
startedAt: started.createdAt,
|
|
92
|
+
updatedAt,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
close() {
|
|
96
|
+
this.database.close();
|
|
97
|
+
}
|
|
98
|
+
readRows(activityId) {
|
|
99
|
+
return this.database.sqlite.prepare(`select * from activity_audit_events
|
|
100
|
+
where activity_id = ?
|
|
101
|
+
order by sequence asc`).all(activityId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function eventInputToRow(input, identity) {
|
|
105
|
+
if (input.type === "started") {
|
|
106
|
+
return {
|
|
107
|
+
id: identity.id,
|
|
108
|
+
activity_id: input.activityId,
|
|
109
|
+
sequence: identity.sequence,
|
|
110
|
+
event_type: input.type,
|
|
111
|
+
turn_id: input.turnId,
|
|
112
|
+
conversation_scope_id: input.conversationScopeId ?? null,
|
|
113
|
+
tool: input.tool,
|
|
114
|
+
workspace_id: input.workspace.id ?? null,
|
|
115
|
+
workspace_root: input.workspace.root,
|
|
116
|
+
workspace_mode: input.workspace.mode,
|
|
117
|
+
workspace_source_root: input.workspace.sourceRoot ?? null,
|
|
118
|
+
workspace_branch: input.workspace.branch ?? null,
|
|
119
|
+
workspace_target_branch: input.workspace.targetBranch ?? null,
|
|
120
|
+
request_json: serializeJson(input.request),
|
|
121
|
+
result_json: null,
|
|
122
|
+
error: null,
|
|
123
|
+
created_at: identity.createdAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
id: identity.id,
|
|
128
|
+
activity_id: input.activityId,
|
|
129
|
+
sequence: identity.sequence,
|
|
130
|
+
event_type: input.type,
|
|
131
|
+
turn_id: null,
|
|
132
|
+
conversation_scope_id: null,
|
|
133
|
+
tool: null,
|
|
134
|
+
workspace_id: null,
|
|
135
|
+
workspace_root: null,
|
|
136
|
+
workspace_mode: null,
|
|
137
|
+
workspace_source_root: null,
|
|
138
|
+
workspace_branch: null,
|
|
139
|
+
workspace_target_branch: null,
|
|
140
|
+
request_json: null,
|
|
141
|
+
result_json: "result" in input ? serializeJson(input.result) : null,
|
|
142
|
+
error: "error" in input ? input.error : null,
|
|
143
|
+
created_at: identity.createdAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function rowToEvent(row) {
|
|
147
|
+
const base = {
|
|
148
|
+
id: row.id,
|
|
149
|
+
activityId: row.activity_id,
|
|
150
|
+
sequence: row.sequence,
|
|
151
|
+
createdAt: row.created_at,
|
|
152
|
+
};
|
|
153
|
+
switch (row.event_type) {
|
|
154
|
+
case "started":
|
|
155
|
+
if (!row.turn_id || !row.tool || !row.workspace_root || !isWorkspaceMode(row.workspace_mode)) {
|
|
156
|
+
throw new Error(`Activity audit start event ${row.id} is missing required context.`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
...base,
|
|
160
|
+
type: "started",
|
|
161
|
+
turnId: row.turn_id,
|
|
162
|
+
...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
|
|
163
|
+
tool: row.tool,
|
|
164
|
+
workspace: {
|
|
165
|
+
...(row.workspace_id ? { id: row.workspace_id } : {}),
|
|
166
|
+
root: row.workspace_root,
|
|
167
|
+
mode: row.workspace_mode,
|
|
168
|
+
...(row.workspace_source_root ? { sourceRoot: row.workspace_source_root } : {}),
|
|
169
|
+
...(row.workspace_branch ? { branch: row.workspace_branch } : {}),
|
|
170
|
+
...(row.workspace_target_branch ? { targetBranch: row.workspace_target_branch } : {}),
|
|
171
|
+
},
|
|
172
|
+
...(row.request_json !== null ? { request: parseJson(row.request_json) } : {}),
|
|
173
|
+
};
|
|
174
|
+
case "succeeded":
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
type: "succeeded",
|
|
178
|
+
result: parseJson(row.result_json),
|
|
179
|
+
};
|
|
180
|
+
case "failed":
|
|
181
|
+
if (!row.error)
|
|
182
|
+
throw new Error(`Activity audit failed event ${row.id} is missing an error.`);
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
type: "failed",
|
|
186
|
+
result: parseJson(row.result_json),
|
|
187
|
+
error: row.error,
|
|
188
|
+
};
|
|
189
|
+
case "blocked":
|
|
190
|
+
if (!row.error)
|
|
191
|
+
throw new Error(`Activity audit blocked event ${row.id} is missing an error.`);
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
type: "blocked",
|
|
195
|
+
error: row.error,
|
|
196
|
+
};
|
|
197
|
+
default:
|
|
198
|
+
throw new Error(`Unknown Activity audit event type: ${row.event_type}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function isWorkspaceMode(value) {
|
|
202
|
+
return value === "checkout" || value === "worktree";
|
|
203
|
+
}
|
|
204
|
+
function serializeJson(value) {
|
|
205
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
206
|
+
}
|
|
207
|
+
function parseJson(value) {
|
|
208
|
+
return value === null ? undefined : JSON.parse(value);
|
|
209
|
+
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -34,6 +34,11 @@ const migrations = [
|
|
|
34
34
|
name: "workspace-context-deliveries",
|
|
35
35
|
up: migrateWorkspaceContextDeliveries,
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
version: 8,
|
|
39
|
+
name: "activity-audit",
|
|
40
|
+
up: migrateActivityAudit,
|
|
41
|
+
},
|
|
37
42
|
];
|
|
38
43
|
export function migrateDatabase(sqlite) {
|
|
39
44
|
const migrate = sqlite.transaction(() => {
|
|
@@ -208,6 +213,41 @@ function migrateWorkspaceContextDeliveries(sqlite) {
|
|
|
208
213
|
on workspace_context_deliveries(delivered_at desc);
|
|
209
214
|
`);
|
|
210
215
|
}
|
|
216
|
+
function migrateActivityAudit(sqlite) {
|
|
217
|
+
sqlite.exec(`
|
|
218
|
+
create table if not exists activity_audit_events (
|
|
219
|
+
id text primary key,
|
|
220
|
+
activity_id text not null,
|
|
221
|
+
sequence integer not null,
|
|
222
|
+
event_type text not null,
|
|
223
|
+
turn_id text,
|
|
224
|
+
conversation_scope_id text,
|
|
225
|
+
tool text,
|
|
226
|
+
workspace_id text,
|
|
227
|
+
workspace_root text,
|
|
228
|
+
workspace_mode text,
|
|
229
|
+
workspace_source_root text,
|
|
230
|
+
workspace_branch text,
|
|
231
|
+
workspace_target_branch text,
|
|
232
|
+
request_json text,
|
|
233
|
+
result_json text,
|
|
234
|
+
error text,
|
|
235
|
+
created_at text not null
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
create unique index if not exists activity_audit_events_activity_sequence_unique_idx
|
|
239
|
+
on activity_audit_events(activity_id, sequence);
|
|
240
|
+
|
|
241
|
+
create index if not exists activity_audit_events_activity_idx
|
|
242
|
+
on activity_audit_events(activity_id, sequence);
|
|
243
|
+
|
|
244
|
+
create index if not exists activity_audit_events_turn_idx
|
|
245
|
+
on activity_audit_events(turn_id, created_at);
|
|
246
|
+
|
|
247
|
+
create index if not exists activity_audit_events_created_idx
|
|
248
|
+
on activity_audit_events(created_at);
|
|
249
|
+
`);
|
|
250
|
+
}
|
|
211
251
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
212
252
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
213
253
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { 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(),
|
|
@@ -73,6 +73,30 @@ export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
|
|
73
73
|
expiresAt: integer("expires_at").notNull(),
|
|
74
74
|
resource: text("resource"),
|
|
75
75
|
});
|
|
76
|
+
export const activityAuditEvents = sqliteTable("activity_audit_events", {
|
|
77
|
+
id: text("id").primaryKey(),
|
|
78
|
+
activityId: text("activity_id").notNull(),
|
|
79
|
+
sequence: integer("sequence").notNull(),
|
|
80
|
+
eventType: text("event_type").notNull(),
|
|
81
|
+
turnId: text("turn_id"),
|
|
82
|
+
conversationScopeId: text("conversation_scope_id"),
|
|
83
|
+
tool: text("tool"),
|
|
84
|
+
workspaceId: text("workspace_id"),
|
|
85
|
+
workspaceRoot: text("workspace_root"),
|
|
86
|
+
workspaceMode: text("workspace_mode"),
|
|
87
|
+
workspaceSourceRoot: text("workspace_source_root"),
|
|
88
|
+
workspaceBranch: text("workspace_branch"),
|
|
89
|
+
workspaceTargetBranch: text("workspace_target_branch"),
|
|
90
|
+
requestJson: text("request_json"),
|
|
91
|
+
resultJson: text("result_json"),
|
|
92
|
+
error: text("error"),
|
|
93
|
+
createdAt: text("created_at").notNull(),
|
|
94
|
+
}, (table) => [
|
|
95
|
+
uniqueIndex("activity_audit_events_activity_sequence_unique_idx").on(table.activityId, table.sequence),
|
|
96
|
+
index("activity_audit_events_activity_idx").on(table.activityId, table.sequence),
|
|
97
|
+
index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
|
|
98
|
+
index("activity_audit_events_created_idx").on(table.createdAt),
|
|
99
|
+
]);
|
|
76
100
|
export const localAgentSessions = sqliteTable("local_agent_sessions", {
|
|
77
101
|
id: text("id").primaryKey(),
|
|
78
102
|
workspaceId: text("workspace_id"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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/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/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",
|