@fenglimg/fabric-server 0.1.0 → 1.0.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/chunk-KZO24GUQ.js +199 -0
- package/dist/http-3BNWQWPP.js +1608 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +155 -198
- package/dist/static/assets/index-D45wW11O.css +1 -0
- package/dist/static/assets/index-D_EcxWYV.js +5 -0
- package/dist/static/index.html +14 -0
- package/package.json +13 -8
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentsMetaFileMissingError,
|
|
3
|
+
AgentsMetaInvalidError,
|
|
4
|
+
appendLedgerEntry,
|
|
5
|
+
assertPathWithinProjectRoot,
|
|
6
|
+
atomicWriteText,
|
|
7
|
+
hashHumanLockedContent,
|
|
8
|
+
readAgentsMeta,
|
|
9
|
+
readHumanLock,
|
|
10
|
+
readHumanLockDocument,
|
|
11
|
+
readHumanLockEntry,
|
|
12
|
+
readLedger
|
|
13
|
+
} from "./chunk-KZO24GUQ.js";
|
|
14
|
+
|
|
15
|
+
// src/http.ts
|
|
16
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
17
|
+
import { appendFile, readFile as readFile3 } from "fs/promises";
|
|
18
|
+
import { join as join4 } from "path";
|
|
19
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
20
|
+
import {
|
|
21
|
+
StreamableHTTPServerTransport
|
|
22
|
+
} from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
|
|
24
|
+
// src/services/doctor.ts
|
|
25
|
+
import { createHash } from "crypto";
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
27
|
+
import { readFile } from "fs/promises";
|
|
28
|
+
import { isAbsolute, join, posix, resolve } from "path";
|
|
29
|
+
import { detectFramework, forensicReportSchema } from "@fenglimg/fabric-shared";
|
|
30
|
+
var SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
31
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
32
|
+
".fabric",
|
|
33
|
+
".git",
|
|
34
|
+
".next",
|
|
35
|
+
".turbo",
|
|
36
|
+
"Library",
|
|
37
|
+
"Temp",
|
|
38
|
+
"build",
|
|
39
|
+
"coverage",
|
|
40
|
+
"dist",
|
|
41
|
+
"node_modules"
|
|
42
|
+
]);
|
|
43
|
+
var LEDGER_WARN_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
|
|
44
|
+
var LEDGER_ERROR_AFTER_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
45
|
+
async function runDoctorReport(target) {
|
|
46
|
+
const projectRoot = normalizeTarget(target);
|
|
47
|
+
const framework = detectFramework(projectRoot);
|
|
48
|
+
const entryPoints = collectEntryPoints(projectRoot);
|
|
49
|
+
const [savedForensic, metaSnapshot, humanLockSnapshot, ledgerSnapshot] = await Promise.all([
|
|
50
|
+
readSavedForensic(projectRoot),
|
|
51
|
+
inspectMetaRevision(projectRoot),
|
|
52
|
+
inspectHumanLock(projectRoot),
|
|
53
|
+
inspectLedger(projectRoot)
|
|
54
|
+
]);
|
|
55
|
+
const checks = [
|
|
56
|
+
createForensicCheck(savedForensic, framework, entryPoints),
|
|
57
|
+
createFrameworkCheck(savedForensic, framework, entryPoints),
|
|
58
|
+
createMetaRevisionCheck(metaSnapshot),
|
|
59
|
+
createProtectedPathsCheck(humanLockSnapshot),
|
|
60
|
+
createLedgerCheck(ledgerSnapshot)
|
|
61
|
+
];
|
|
62
|
+
return {
|
|
63
|
+
status: reduceStatus(checks.map((check) => check.status)),
|
|
64
|
+
checks,
|
|
65
|
+
summary: {
|
|
66
|
+
target: projectRoot,
|
|
67
|
+
framework: {
|
|
68
|
+
kind: framework.kind,
|
|
69
|
+
version: framework.version,
|
|
70
|
+
subkind: framework.subkind
|
|
71
|
+
},
|
|
72
|
+
entryPoints,
|
|
73
|
+
driftCount: humanLockSnapshot.driftCount,
|
|
74
|
+
protectedPathCount: humanLockSnapshot.protectedPathCount,
|
|
75
|
+
protectedPathsIntact: humanLockSnapshot.present && humanLockSnapshot.driftCount === 0,
|
|
76
|
+
lastLedgerEntryTs: ledgerSnapshot.lastEntryTs,
|
|
77
|
+
lastLedgerEntryAgeMs: ledgerSnapshot.lastEntryAgeMs,
|
|
78
|
+
metaRevision: metaSnapshot.revision
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function createForensicCheck(forensic, framework, entryPoints) {
|
|
83
|
+
if (!forensic.present) {
|
|
84
|
+
return {
|
|
85
|
+
name: "Forensic snapshot",
|
|
86
|
+
status: "error",
|
|
87
|
+
message: `${forensic.reason} Live scan detects ${formatFramework(framework)} with ${entryPoints.length} entry point${entryPoints.length === 1 ? "" : "s"}.`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
name: "Forensic snapshot",
|
|
92
|
+
status: "ok",
|
|
93
|
+
message: `Loaded .fabric/forensic.json for ${formatFramework(forensic.report.framework)} with ${forensic.report.entry_points.length} recorded entry point${forensic.report.entry_points.length === 1 ? "" : "s"}.`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function createFrameworkCheck(forensic, framework, entryPoints) {
|
|
97
|
+
if (framework.kind === "unknown") {
|
|
98
|
+
return {
|
|
99
|
+
name: "Framework fingerprint",
|
|
100
|
+
status: "warn",
|
|
101
|
+
message: "Unable to identify the project framework from current files."
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!forensic.present) {
|
|
105
|
+
return {
|
|
106
|
+
name: "Framework fingerprint",
|
|
107
|
+
status: "warn",
|
|
108
|
+
message: `Live detection sees ${formatFramework(framework)} and ${entryPoints.length} entry point${entryPoints.length === 1 ? "" : "s"}, but no forensic baseline exists yet.`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const matches = forensic.report.framework.kind === framework.kind && forensic.report.framework.version === framework.version && forensic.report.framework.subkind === framework.subkind;
|
|
112
|
+
if (!matches) {
|
|
113
|
+
return {
|
|
114
|
+
name: "Framework fingerprint",
|
|
115
|
+
status: "warn",
|
|
116
|
+
message: `Forensic baseline says ${formatFramework(forensic.report.framework)}; live scan says ${formatFramework(framework)}.`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
name: "Framework fingerprint",
|
|
121
|
+
status: "ok",
|
|
122
|
+
message: `Framework baseline matches live scan: ${formatFramework(framework)} \xB7 ${entryPoints.length} current entry point${entryPoints.length === 1 ? "" : "s"}.`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createMetaRevisionCheck(snapshot) {
|
|
126
|
+
if (!snapshot.present) {
|
|
127
|
+
return {
|
|
128
|
+
name: "Meta revision",
|
|
129
|
+
status: "error",
|
|
130
|
+
message: snapshot.unexpectedError ?? "agents.meta.json is missing."
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (snapshot.driftCount > 0 || snapshot.missingFiles.length > 0) {
|
|
134
|
+
const parts = [
|
|
135
|
+
`${snapshot.driftCount} tracked AGENTS file drift`,
|
|
136
|
+
snapshot.missingFiles.length > 0 ? `${snapshot.missingFiles.length} missing tracked file` : null
|
|
137
|
+
].filter((part) => part !== null);
|
|
138
|
+
return {
|
|
139
|
+
name: "Meta revision",
|
|
140
|
+
status: "error",
|
|
141
|
+
message: `agents.meta.json revision ${snapshot.revision} is stale: ${parts.join(" \xB7 ")}.`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
name: "Meta revision",
|
|
146
|
+
status: "ok",
|
|
147
|
+
message: `agents.meta.json revision ${snapshot.revision} matches ${snapshot.nodeCount} tracked AGENTS file${snapshot.nodeCount === 1 ? "" : "s"}.`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function createProtectedPathsCheck(snapshot) {
|
|
151
|
+
if (!snapshot.present) {
|
|
152
|
+
return {
|
|
153
|
+
name: "Protected paths",
|
|
154
|
+
status: "warn",
|
|
155
|
+
message: snapshot.reason
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (snapshot.driftCount > 0) {
|
|
159
|
+
return {
|
|
160
|
+
name: "Protected paths",
|
|
161
|
+
status: "warn",
|
|
162
|
+
message: `${snapshot.driftCount} of ${snapshot.protectedPathCount} protected path${snapshot.protectedPathCount === 1 ? "" : "s"} drifted from approved hashes.`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
name: "Protected paths",
|
|
167
|
+
status: "ok",
|
|
168
|
+
message: `${snapshot.protectedPathCount} protected path${snapshot.protectedPathCount === 1 ? "" : "s"} intact with zero hash drift.`
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function createLedgerCheck(snapshot) {
|
|
172
|
+
if (snapshot.lastEntryTs === null || snapshot.lastEntryAgeMs === null) {
|
|
173
|
+
return {
|
|
174
|
+
name: "Intent ledger",
|
|
175
|
+
status: "warn",
|
|
176
|
+
message: "No ledger entries recorded yet."
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (snapshot.lastEntryAgeMs >= LEDGER_ERROR_AFTER_MS) {
|
|
180
|
+
return {
|
|
181
|
+
name: "Intent ledger",
|
|
182
|
+
status: "error",
|
|
183
|
+
message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${new Date(snapshot.lastEntryTs).toISOString()}).`
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (snapshot.lastEntryAgeMs >= LEDGER_WARN_AFTER_MS) {
|
|
187
|
+
return {
|
|
188
|
+
name: "Intent ledger",
|
|
189
|
+
status: "warn",
|
|
190
|
+
message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${new Date(snapshot.lastEntryTs).toISOString()}).`
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
name: "Intent ledger",
|
|
195
|
+
status: "ok",
|
|
196
|
+
message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${snapshot.count} total entr${snapshot.count === 1 ? "y" : "ies"}).`
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function readSavedForensic(projectRoot) {
|
|
200
|
+
const forensicPath = join(projectRoot, ".fabric", "forensic.json");
|
|
201
|
+
try {
|
|
202
|
+
const raw = await readFile(forensicPath, "utf8");
|
|
203
|
+
const parsed = forensicReportSchema.safeParse(JSON.parse(raw));
|
|
204
|
+
if (!parsed.success) {
|
|
205
|
+
return {
|
|
206
|
+
present: false,
|
|
207
|
+
reason: "forensic.json is invalid."
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
present: true,
|
|
212
|
+
report: parsed.data
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (isMissingFileError(error)) {
|
|
216
|
+
return {
|
|
217
|
+
present: false,
|
|
218
|
+
reason: ".fabric/forensic.json is missing."
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
present: false,
|
|
223
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function inspectMetaRevision(projectRoot) {
|
|
228
|
+
try {
|
|
229
|
+
const meta = readAgentsMeta(projectRoot);
|
|
230
|
+
const entries = Object.entries(meta.nodes).sort(([left], [right]) => left.localeCompare(right));
|
|
231
|
+
const missingFiles = [];
|
|
232
|
+
let driftCount = 0;
|
|
233
|
+
const revisionSource = entries.map(([, node]) => {
|
|
234
|
+
const absolutePath = join(projectRoot, node.file);
|
|
235
|
+
if (!existsSync(absolutePath)) {
|
|
236
|
+
missingFiles.push(node.file);
|
|
237
|
+
driftCount += 1;
|
|
238
|
+
return "missing";
|
|
239
|
+
}
|
|
240
|
+
const actualHash = sha256(readFileSync(absolutePath, "utf8"));
|
|
241
|
+
if (actualHash !== node.hash) {
|
|
242
|
+
driftCount += 1;
|
|
243
|
+
}
|
|
244
|
+
return actualHash;
|
|
245
|
+
}).join("");
|
|
246
|
+
const revision = sha256(revisionSource);
|
|
247
|
+
return {
|
|
248
|
+
present: true,
|
|
249
|
+
revision: meta.revision,
|
|
250
|
+
nodeCount: entries.length,
|
|
251
|
+
driftCount: revision === meta.revision ? driftCount : Math.max(driftCount, 1),
|
|
252
|
+
missingFiles
|
|
253
|
+
};
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
present: false,
|
|
257
|
+
revision: null,
|
|
258
|
+
nodeCount: 0,
|
|
259
|
+
driftCount: 0,
|
|
260
|
+
missingFiles: [],
|
|
261
|
+
unexpectedError: error instanceof Error ? error.message : String(error)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function inspectHumanLock(projectRoot) {
|
|
266
|
+
try {
|
|
267
|
+
const entries = await readHumanLock(projectRoot);
|
|
268
|
+
return {
|
|
269
|
+
present: true,
|
|
270
|
+
driftCount: entries.filter((entry) => entry.drift).length,
|
|
271
|
+
protectedPathCount: entries.length
|
|
272
|
+
};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (isMissingFileError(error)) {
|
|
275
|
+
return {
|
|
276
|
+
present: false,
|
|
277
|
+
driftCount: 0,
|
|
278
|
+
protectedPathCount: 0,
|
|
279
|
+
reason: ".fabric/human-lock.json is missing; no protected paths are being tracked."
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
present: false,
|
|
284
|
+
driftCount: 0,
|
|
285
|
+
protectedPathCount: 0,
|
|
286
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function inspectLedger(projectRoot) {
|
|
291
|
+
const entries = await readLedger(projectRoot);
|
|
292
|
+
const lastEntry = entries.reduce(
|
|
293
|
+
(latest, entry) => latest === null || entry.ts > latest ? entry.ts : latest,
|
|
294
|
+
null
|
|
295
|
+
);
|
|
296
|
+
return {
|
|
297
|
+
count: entries.length,
|
|
298
|
+
lastEntryTs: lastEntry,
|
|
299
|
+
lastEntryAgeMs: lastEntry === null ? null : Math.max(Date.now() - lastEntry, 0)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function normalizeTarget(targetInput) {
|
|
303
|
+
return isAbsolute(targetInput) ? targetInput : resolve(process.cwd(), targetInput);
|
|
304
|
+
}
|
|
305
|
+
function collectEntryPoints(root) {
|
|
306
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const entries = [];
|
|
310
|
+
const stack = [root];
|
|
311
|
+
while (stack.length > 0) {
|
|
312
|
+
const current = stack.pop();
|
|
313
|
+
if (current === void 0) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
317
|
+
const absolutePath = join(current, entry.name);
|
|
318
|
+
const relativePath = posix.normalize(absolutePath.slice(root.length + 1).split("\\").join("/"));
|
|
319
|
+
if (relativePath.length === 0) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (entry.isDirectory()) {
|
|
323
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
stack.push(absolutePath);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!entry.isFile()) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const reason = getEntryPointReason(relativePath);
|
|
333
|
+
if (reason !== null) {
|
|
334
|
+
entries.push({
|
|
335
|
+
path: relativePath,
|
|
336
|
+
reason
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
342
|
+
}
|
|
343
|
+
function getEntryPointReason(relativePath) {
|
|
344
|
+
const extension = relativePath.slice(relativePath.lastIndexOf("."));
|
|
345
|
+
if (!SCRIPT_EXTENSIONS.has(extension)) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const directory = posix.dirname(relativePath);
|
|
349
|
+
const fileName = posix.basename(relativePath);
|
|
350
|
+
const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
|
|
351
|
+
if (directory === "assets/scripts" || directory === "scripts") {
|
|
352
|
+
return "top-level script";
|
|
353
|
+
}
|
|
354
|
+
if (directory === "src" && /^(App|app|index|main)$/.test(fileBase)) {
|
|
355
|
+
return "application entry";
|
|
356
|
+
}
|
|
357
|
+
if ((directory === "app" || directory.startsWith("app/")) && /^(layout|page|route)$/.test(fileBase)) {
|
|
358
|
+
return "next app route";
|
|
359
|
+
}
|
|
360
|
+
if ((directory === "pages" || directory.startsWith("pages/")) && fileName !== "_app.d.ts") {
|
|
361
|
+
return "next page route";
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
function reduceStatus(statuses) {
|
|
366
|
+
if (statuses.includes("error")) {
|
|
367
|
+
return "error";
|
|
368
|
+
}
|
|
369
|
+
if (statuses.includes("warn")) {
|
|
370
|
+
return "warn";
|
|
371
|
+
}
|
|
372
|
+
return "ok";
|
|
373
|
+
}
|
|
374
|
+
function formatFramework(framework) {
|
|
375
|
+
const pieces = [framework.kind, framework.version !== "unknown" ? framework.version : null, framework.subkind].filter((piece) => piece !== null && piece !== "unknown");
|
|
376
|
+
return pieces.length > 0 ? pieces.join(" \xB7 ") : "unknown";
|
|
377
|
+
}
|
|
378
|
+
function formatAge(ageMs) {
|
|
379
|
+
const seconds = Math.floor(ageMs / 1e3);
|
|
380
|
+
if (seconds < 60) {
|
|
381
|
+
return `${seconds}s`;
|
|
382
|
+
}
|
|
383
|
+
const minutes = Math.floor(seconds / 60);
|
|
384
|
+
if (minutes < 60) {
|
|
385
|
+
return `${minutes}m`;
|
|
386
|
+
}
|
|
387
|
+
const hours = Math.floor(minutes / 60);
|
|
388
|
+
if (hours < 48) {
|
|
389
|
+
return `${hours}h`;
|
|
390
|
+
}
|
|
391
|
+
const days = Math.floor(hours / 24);
|
|
392
|
+
if (days < 14) {
|
|
393
|
+
return `${days}d`;
|
|
394
|
+
}
|
|
395
|
+
return `${Math.floor(days / 7)}w`;
|
|
396
|
+
}
|
|
397
|
+
function sha256(content) {
|
|
398
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
399
|
+
}
|
|
400
|
+
function isMissingFileError(error) {
|
|
401
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/api/_error.ts
|
|
405
|
+
function sendError(res, status, code, message, details) {
|
|
406
|
+
const payload = {
|
|
407
|
+
error: {
|
|
408
|
+
code,
|
|
409
|
+
message
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
if (details !== void 0) {
|
|
413
|
+
payload.error.details = details;
|
|
414
|
+
}
|
|
415
|
+
res.status(status).json(payload);
|
|
416
|
+
}
|
|
417
|
+
function sendValidationError(res, message, details) {
|
|
418
|
+
sendError(res, 400, "BAD_REQUEST", message, details);
|
|
419
|
+
}
|
|
420
|
+
function sendUnknownError(res, error) {
|
|
421
|
+
const normalized = normalizeApiError(error);
|
|
422
|
+
sendError(res, normalized.status, normalized.code, normalized.message, normalized.details);
|
|
423
|
+
}
|
|
424
|
+
function normalizeApiError(error) {
|
|
425
|
+
if (error instanceof Error && "status" in error && "code" in error && typeof error.status === "number" && typeof error.code === "string") {
|
|
426
|
+
return {
|
|
427
|
+
status: error.status,
|
|
428
|
+
code: error.code,
|
|
429
|
+
message: error.message
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (error instanceof AgentsMetaFileMissingError) {
|
|
433
|
+
return {
|
|
434
|
+
status: 404,
|
|
435
|
+
code: error.code,
|
|
436
|
+
message: error.message
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (error instanceof AgentsMetaInvalidError) {
|
|
440
|
+
return {
|
|
441
|
+
status: 500,
|
|
442
|
+
code: error.code,
|
|
443
|
+
message: error.message
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (error instanceof Error) {
|
|
447
|
+
if (error.message.startsWith("Path escapes project root:")) {
|
|
448
|
+
return {
|
|
449
|
+
status: 403,
|
|
450
|
+
code: "PATH_OUTSIDE_PROJECT_ROOT",
|
|
451
|
+
message: error.message
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (error.message.startsWith("Cannot find human lock entry:")) {
|
|
455
|
+
return {
|
|
456
|
+
status: 404,
|
|
457
|
+
code: "HUMAN_LOCK_ENTRY_NOT_FOUND",
|
|
458
|
+
message: error.message
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (error.message.startsWith("Cannot find ledger entry:")) {
|
|
462
|
+
return {
|
|
463
|
+
status: 404,
|
|
464
|
+
code: "LEDGER_ENTRY_NOT_FOUND",
|
|
465
|
+
message: error.message
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
status: 500,
|
|
470
|
+
code: "INTERNAL_ERROR",
|
|
471
|
+
message: error.message
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
status: 500,
|
|
476
|
+
code: "INTERNAL_ERROR",
|
|
477
|
+
message: `Unexpected error: ${String(error)}`
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/api/doctor.ts
|
|
482
|
+
function registerDoctorApi(app, projectRoot) {
|
|
483
|
+
app.get("/api/doctor", async (_req, res) => {
|
|
484
|
+
try {
|
|
485
|
+
res.json(await runDoctorReport(projectRoot));
|
|
486
|
+
} catch (error) {
|
|
487
|
+
sendUnknownError(res, error);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/api/events.ts
|
|
493
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
494
|
+
import { open, readFile as readFile2, stat } from "fs/promises";
|
|
495
|
+
import { join as join2 } from "path";
|
|
496
|
+
import {
|
|
497
|
+
agentsMetaSchema,
|
|
498
|
+
fabricEventSchema,
|
|
499
|
+
forensicReportSchema as forensicReportSchema2,
|
|
500
|
+
humanLockFileSchema,
|
|
501
|
+
ledgerEntrySchema
|
|
502
|
+
} from "@fenglimg/fabric-shared";
|
|
503
|
+
import chokidar from "chokidar";
|
|
504
|
+
var AGENTS_META_PATH = ".fabric/agents.meta.json";
|
|
505
|
+
var HUMAN_LOCK_PATH = ".fabric/human-lock.json";
|
|
506
|
+
var FORENSIC_PATH = ".fabric/forensic.json";
|
|
507
|
+
var LEDGER_PATH = ".intent-ledger.jsonl";
|
|
508
|
+
var WATCHED_PATHS = [AGENTS_META_PATH, HUMAN_LOCK_PATH, FORENSIC_PATH, LEDGER_PATH];
|
|
509
|
+
var CONNECTION_LIMIT = 10;
|
|
510
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
511
|
+
var WATCH_DEBOUNCE_MS = 75;
|
|
512
|
+
function createEventsHandler(options) {
|
|
513
|
+
const { projectRoot } = options;
|
|
514
|
+
const state = {
|
|
515
|
+
clients: /* @__PURE__ */ new Set(),
|
|
516
|
+
pendingTimers: /* @__PURE__ */ new Map(),
|
|
517
|
+
ledgerOffset: 0,
|
|
518
|
+
ledgerRemainder: "",
|
|
519
|
+
humanLockSnapshot: createEmptyHumanLockSnapshot()
|
|
520
|
+
};
|
|
521
|
+
return async function handleEvents(req, res) {
|
|
522
|
+
if (state.clients.size >= CONNECTION_LIMIT) {
|
|
523
|
+
res.statusCode = 503;
|
|
524
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
525
|
+
res.end(
|
|
526
|
+
JSON.stringify({
|
|
527
|
+
error: {
|
|
528
|
+
code: "SSE_CONNECTION_LIMIT",
|
|
529
|
+
message: `Too many SSE clients connected. Limit: ${CONNECTION_LIMIT}.`
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await ensureWatcher(state, projectRoot);
|
|
536
|
+
res.statusCode = 200;
|
|
537
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
538
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
539
|
+
res.setHeader("Connection", "keep-alive");
|
|
540
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
541
|
+
res.flushHeaders?.();
|
|
542
|
+
res.write(": connected\n\n");
|
|
543
|
+
state.clients.add(res);
|
|
544
|
+
const heartbeat = setInterval(() => {
|
|
545
|
+
if (!res.writableEnded) {
|
|
546
|
+
res.write(": ping\n\n");
|
|
547
|
+
}
|
|
548
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
549
|
+
let cleanedUp = false;
|
|
550
|
+
const cleanup = async () => {
|
|
551
|
+
if (cleanedUp) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
cleanedUp = true;
|
|
555
|
+
clearInterval(heartbeat);
|
|
556
|
+
state.clients.delete(res);
|
|
557
|
+
if (state.clients.size === 0) {
|
|
558
|
+
await stopWatcher(state);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
req.on("aborted", () => {
|
|
562
|
+
void cleanup();
|
|
563
|
+
});
|
|
564
|
+
req.on("close", () => {
|
|
565
|
+
void cleanup();
|
|
566
|
+
});
|
|
567
|
+
res.on("close", () => {
|
|
568
|
+
void cleanup();
|
|
569
|
+
});
|
|
570
|
+
res.on("error", () => {
|
|
571
|
+
void cleanup();
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
async function ensureWatcher(state, projectRoot) {
|
|
576
|
+
if (state.watcher !== void 0) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
state.ledgerOffset = await readFileSize(join2(projectRoot, LEDGER_PATH));
|
|
580
|
+
state.ledgerRemainder = "";
|
|
581
|
+
state.humanLockSnapshot = await readHumanLockSnapshot(projectRoot);
|
|
582
|
+
const watcher = chokidar.watch([...WATCHED_PATHS], {
|
|
583
|
+
cwd: projectRoot,
|
|
584
|
+
ignoreInitial: true,
|
|
585
|
+
awaitWriteFinish: {
|
|
586
|
+
stabilityThreshold: 120,
|
|
587
|
+
pollInterval: 20
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
watcher.on("add", (relativePath) => {
|
|
591
|
+
scheduleFileChange(state, projectRoot, normalizePath(relativePath));
|
|
592
|
+
});
|
|
593
|
+
watcher.on("change", (relativePath) => {
|
|
594
|
+
scheduleFileChange(state, projectRoot, normalizePath(relativePath));
|
|
595
|
+
});
|
|
596
|
+
state.watcher = watcher;
|
|
597
|
+
}
|
|
598
|
+
async function stopWatcher(state) {
|
|
599
|
+
const watcher = state.watcher;
|
|
600
|
+
if (watcher === void 0) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
state.watcher = void 0;
|
|
604
|
+
for (const timer of state.pendingTimers.values()) {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
}
|
|
607
|
+
state.pendingTimers.clear();
|
|
608
|
+
await watcher.close();
|
|
609
|
+
}
|
|
610
|
+
function scheduleFileChange(state, projectRoot, relativePath) {
|
|
611
|
+
if (!WATCHED_PATHS.includes(relativePath)) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const existingTimer = state.pendingTimers.get(relativePath);
|
|
615
|
+
if (existingTimer !== void 0) {
|
|
616
|
+
clearTimeout(existingTimer);
|
|
617
|
+
}
|
|
618
|
+
const timer = setTimeout(() => {
|
|
619
|
+
state.pendingTimers.delete(relativePath);
|
|
620
|
+
void publishFileChange(state, projectRoot, relativePath);
|
|
621
|
+
}, WATCH_DEBOUNCE_MS);
|
|
622
|
+
state.pendingTimers.set(relativePath, timer);
|
|
623
|
+
}
|
|
624
|
+
async function publishFileChange(state, projectRoot, relativePath) {
|
|
625
|
+
const events = await readEventsForFile(state, projectRoot, relativePath);
|
|
626
|
+
for (const event of events) {
|
|
627
|
+
broadcastEvent(state, event);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function readEventsForFile(state, projectRoot, relativePath) {
|
|
631
|
+
if (relativePath === AGENTS_META_PATH) {
|
|
632
|
+
const event = await readMetaUpdatedEvent(projectRoot);
|
|
633
|
+
return event === null ? [] : [event];
|
|
634
|
+
}
|
|
635
|
+
if (relativePath === HUMAN_LOCK_PATH) {
|
|
636
|
+
return await readHumanLockEvents(state, projectRoot);
|
|
637
|
+
}
|
|
638
|
+
if (relativePath === FORENSIC_PATH) {
|
|
639
|
+
const event = await readDriftDetectedEvent(projectRoot);
|
|
640
|
+
return event === null ? [] : [event];
|
|
641
|
+
}
|
|
642
|
+
if (relativePath === LEDGER_PATH) {
|
|
643
|
+
return await readLedgerAppendedEvents(state, projectRoot);
|
|
644
|
+
}
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
async function readMetaUpdatedEvent(projectRoot) {
|
|
648
|
+
const filePath = join2(projectRoot, AGENTS_META_PATH);
|
|
649
|
+
const raw = await readUtf8File(filePath);
|
|
650
|
+
if (raw === null) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const parsed = agentsMetaSchema.parse(JSON.parse(raw));
|
|
654
|
+
return {
|
|
655
|
+
type: "meta:updated",
|
|
656
|
+
payload: parsed
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
async function readDriftDetectedEvent(projectRoot) {
|
|
660
|
+
const filePath = join2(projectRoot, FORENSIC_PATH);
|
|
661
|
+
const raw = await readUtf8File(filePath);
|
|
662
|
+
if (raw === null) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
const parsed = forensicReportSchema2.parse(JSON.parse(raw));
|
|
666
|
+
return {
|
|
667
|
+
type: "drift:detected",
|
|
668
|
+
payload: parsed
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async function readHumanLockEvents(state, projectRoot) {
|
|
672
|
+
const previousSnapshot = state.humanLockSnapshot;
|
|
673
|
+
const currentSnapshot = await readHumanLockSnapshot(projectRoot);
|
|
674
|
+
state.humanLockSnapshot = currentSnapshot;
|
|
675
|
+
const changedEntries = currentSnapshot.locked.filter((entry) => {
|
|
676
|
+
const key = getHumanLockKey(entry);
|
|
677
|
+
return previousSnapshot.hashByKey.get(key) !== entry.hash;
|
|
678
|
+
});
|
|
679
|
+
const approvedEntries = changedEntries.filter((entry) => {
|
|
680
|
+
const key = getHumanLockKey(entry);
|
|
681
|
+
return currentSnapshot.actualHashByKey.get(key) === entry.hash;
|
|
682
|
+
});
|
|
683
|
+
const driftChanged = !areSetsEqual(previousSnapshot.driftedKeys, currentSnapshot.driftedKeys);
|
|
684
|
+
const events = [];
|
|
685
|
+
if (approvedEntries.length > 0 || changedEntries.length > 0 && currentSnapshot.drifted.length === 0) {
|
|
686
|
+
events.push({
|
|
687
|
+
type: "lock:approved",
|
|
688
|
+
payload: {
|
|
689
|
+
locked: currentSnapshot.locked,
|
|
690
|
+
approved: approvedEntries.length > 0 ? approvedEntries : changedEntries
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
if (currentSnapshot.drifted.length > 0 && (driftChanged || approvedEntries.length === 0)) {
|
|
695
|
+
events.push({
|
|
696
|
+
type: "lock:drift",
|
|
697
|
+
payload: {
|
|
698
|
+
locked: currentSnapshot.locked,
|
|
699
|
+
drifted: currentSnapshot.drifted
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return events;
|
|
704
|
+
}
|
|
705
|
+
async function readLedgerAppendedEvents(state, projectRoot) {
|
|
706
|
+
const ledgerPath = join2(projectRoot, LEDGER_PATH);
|
|
707
|
+
const nextSize = await readFileSize(ledgerPath);
|
|
708
|
+
if (nextSize < state.ledgerOffset) {
|
|
709
|
+
state.ledgerOffset = 0;
|
|
710
|
+
state.ledgerRemainder = "";
|
|
711
|
+
}
|
|
712
|
+
if (nextSize === state.ledgerOffset) {
|
|
713
|
+
return [];
|
|
714
|
+
}
|
|
715
|
+
const startOffset = state.ledgerOffset;
|
|
716
|
+
state.ledgerOffset = nextSize;
|
|
717
|
+
const handle = await open(ledgerPath, "r");
|
|
718
|
+
try {
|
|
719
|
+
const length = nextSize - startOffset;
|
|
720
|
+
const buffer = Buffer.alloc(length);
|
|
721
|
+
await handle.read(buffer, 0, length, startOffset);
|
|
722
|
+
const chunk = `${state.ledgerRemainder}${buffer.toString("utf8")}`;
|
|
723
|
+
const lines = chunk.split(/\r?\n/);
|
|
724
|
+
state.ledgerRemainder = chunk.endsWith("\n") ? "" : lines.pop() ?? "";
|
|
725
|
+
return lines.map((line) => line.trim()).filter((line) => line.length > 0).map(parseLedgerAppendedEvent).filter((event) => event !== null);
|
|
726
|
+
} finally {
|
|
727
|
+
await handle.close();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function parseLedgerAppendedEvent(line) {
|
|
731
|
+
try {
|
|
732
|
+
const parsed = JSON.parse(line);
|
|
733
|
+
if (parsed.kind === "mcp-event") {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const validation = ledgerEntrySchema.safeParse(parsed);
|
|
737
|
+
if (!validation.success) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
type: "ledger:appended",
|
|
742
|
+
payload: validation.data
|
|
743
|
+
};
|
|
744
|
+
} catch {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function broadcastEvent(state, event) {
|
|
749
|
+
const payload = fabricEventSchema.parse(event);
|
|
750
|
+
const frame = `id: ${randomUUID()}
|
|
751
|
+
event: ${payload.type}
|
|
752
|
+
data: ${JSON.stringify(payload)}
|
|
753
|
+
|
|
754
|
+
`;
|
|
755
|
+
const disconnectedClients = [];
|
|
756
|
+
for (const client of state.clients) {
|
|
757
|
+
try {
|
|
758
|
+
if (client.writableEnded) {
|
|
759
|
+
disconnectedClients.push(client);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
client.write(frame);
|
|
763
|
+
} catch {
|
|
764
|
+
disconnectedClients.push(client);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const client of disconnectedClients) {
|
|
768
|
+
state.clients.delete(client);
|
|
769
|
+
if (!client.writableEnded) {
|
|
770
|
+
client.end();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
async function readHumanLockSnapshot(projectRoot) {
|
|
775
|
+
const humanLockPath = join2(projectRoot, HUMAN_LOCK_PATH);
|
|
776
|
+
const raw = await readUtf8File(humanLockPath);
|
|
777
|
+
if (raw === null) {
|
|
778
|
+
return createEmptyHumanLockSnapshot();
|
|
779
|
+
}
|
|
780
|
+
const parsed = humanLockFileSchema.parse(JSON.parse(raw));
|
|
781
|
+
const locked = parsed.locked ?? [];
|
|
782
|
+
const actualHashByKey = await readActualHumanLockHashes(projectRoot, locked);
|
|
783
|
+
const drifted = locked.filter((entry) => actualHashByKey.get(getHumanLockKey(entry)) !== entry.hash);
|
|
784
|
+
return {
|
|
785
|
+
locked,
|
|
786
|
+
drifted,
|
|
787
|
+
driftedKeys: new Set(drifted.map((entry) => getHumanLockKey(entry))),
|
|
788
|
+
hashByKey: new Map(locked.map((entry) => [getHumanLockKey(entry), entry.hash])),
|
|
789
|
+
actualHashByKey
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
async function readActualHumanLockHashes(projectRoot, locked) {
|
|
793
|
+
const uniqueFiles = Array.from(new Set(locked.map((entry) => entry.file)));
|
|
794
|
+
const fileContents = await Promise.all(
|
|
795
|
+
uniqueFiles.map(async (file) => {
|
|
796
|
+
const raw = await readUtf8File(join2(projectRoot, file));
|
|
797
|
+
return [file, raw];
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
const contentByFile = new Map(fileContents);
|
|
801
|
+
return new Map(
|
|
802
|
+
locked.map((entry) => {
|
|
803
|
+
const content = contentByFile.get(entry.file);
|
|
804
|
+
return [getHumanLockKey(entry), content == null ? "missing" : hashLockedContent(content, entry)];
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
function hashLockedContent(content, entry) {
|
|
809
|
+
const lines = content.split(/\r?\n/);
|
|
810
|
+
const slice = lines.slice(Math.max(entry.start_line - 1, 0), Math.max(entry.end_line, 0)).join("\n");
|
|
811
|
+
return `sha256:${createHash2("sha256").update(slice).digest("hex")}`;
|
|
812
|
+
}
|
|
813
|
+
function getHumanLockKey(entry) {
|
|
814
|
+
return `${entry.file}:${entry.start_line}:${entry.end_line}`;
|
|
815
|
+
}
|
|
816
|
+
function createEmptyHumanLockSnapshot() {
|
|
817
|
+
return {
|
|
818
|
+
locked: [],
|
|
819
|
+
drifted: [],
|
|
820
|
+
driftedKeys: /* @__PURE__ */ new Set(),
|
|
821
|
+
hashByKey: /* @__PURE__ */ new Map(),
|
|
822
|
+
actualHashByKey: /* @__PURE__ */ new Map()
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
function areSetsEqual(left, right) {
|
|
826
|
+
if (left.size !== right.size) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
for (const value of left) {
|
|
830
|
+
if (!right.has(value)) {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
function normalizePath(value) {
|
|
837
|
+
return value.replaceAll("\\", "/");
|
|
838
|
+
}
|
|
839
|
+
async function readUtf8File(path) {
|
|
840
|
+
try {
|
|
841
|
+
return await readFile2(path, "utf8");
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async function readFileSize(path) {
|
|
850
|
+
try {
|
|
851
|
+
const fileStat = await stat(path);
|
|
852
|
+
return fileStat.size;
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
855
|
+
return 0;
|
|
856
|
+
}
|
|
857
|
+
throw error;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function isNodeError(error) {
|
|
861
|
+
return error instanceof Error;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/api/history.ts
|
|
865
|
+
import { historyStateQuerySchema } from "@fenglimg/fabric-shared";
|
|
866
|
+
|
|
867
|
+
// src/services/rehydrate-state.ts
|
|
868
|
+
import { execFile } from "child_process";
|
|
869
|
+
import { promisify } from "util";
|
|
870
|
+
import { agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
|
|
871
|
+
var execFileAsync = promisify(execFile);
|
|
872
|
+
var AGENTS_META_GIT_PATH = ".fabric/agents.meta.json";
|
|
873
|
+
var HistoryReplayError = class extends Error {
|
|
874
|
+
constructor(message, code, status) {
|
|
875
|
+
super(message);
|
|
876
|
+
this.code = code;
|
|
877
|
+
this.status = status;
|
|
878
|
+
this.name = "HistoryReplayError";
|
|
879
|
+
}
|
|
880
|
+
code;
|
|
881
|
+
status;
|
|
882
|
+
};
|
|
883
|
+
async function rehydrateAgentsMetaAt(projectRoot, target) {
|
|
884
|
+
const ledger = await readLedger(projectRoot);
|
|
885
|
+
const selectedIndex = resolveTargetIndex(ledger, target);
|
|
886
|
+
const replayedEntries = ledger.slice(0, selectedIndex + 1);
|
|
887
|
+
const selectedEntry = replayedEntries.at(-1);
|
|
888
|
+
if (selectedEntry === void 0) {
|
|
889
|
+
throw new HistoryReplayError(
|
|
890
|
+
"Cannot rehydrate history state because the ledger is empty.",
|
|
891
|
+
"HISTORY_STATE_NOT_FOUND",
|
|
892
|
+
404
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const commitCandidates = collectCommitCandidates(replayedEntries);
|
|
896
|
+
for (const commit of commitCandidates) {
|
|
897
|
+
const meta = await tryReadAgentsMetaFromGit(projectRoot, commit);
|
|
898
|
+
if (meta !== null) {
|
|
899
|
+
return {
|
|
900
|
+
meta,
|
|
901
|
+
metadata: {
|
|
902
|
+
at_ledger_id: selectedEntry.id,
|
|
903
|
+
at_commit: commit,
|
|
904
|
+
replayed_count: replayedEntries.length,
|
|
905
|
+
mode: "git-show"
|
|
906
|
+
},
|
|
907
|
+
entries: replayedEntries
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const fallbackMeta = buildLedgerFallbackMeta(replayedEntries);
|
|
912
|
+
return {
|
|
913
|
+
meta: fallbackMeta,
|
|
914
|
+
metadata: {
|
|
915
|
+
at_ledger_id: selectedEntry.id,
|
|
916
|
+
at_commit: commitCandidates[0] ?? null,
|
|
917
|
+
replayed_count: replayedEntries.length,
|
|
918
|
+
mode: "ledger-fallback"
|
|
919
|
+
},
|
|
920
|
+
entries: replayedEntries
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function resolveTargetIndex(ledger, target) {
|
|
924
|
+
if ("ledgerEntryId" in target) {
|
|
925
|
+
const index = ledger.findIndex((entry) => entry.id === target.ledgerEntryId);
|
|
926
|
+
if (index === -1) {
|
|
927
|
+
throw new HistoryReplayError(
|
|
928
|
+
`Cannot find ledger entry: ${target.ledgerEntryId}`,
|
|
929
|
+
"LEDGER_ENTRY_NOT_FOUND",
|
|
930
|
+
404
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return index;
|
|
934
|
+
}
|
|
935
|
+
for (let index = ledger.length - 1; index >= 0; index -= 1) {
|
|
936
|
+
if (ledger[index]?.ts <= target.timestamp) {
|
|
937
|
+
return index;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
throw new HistoryReplayError(
|
|
941
|
+
`Cannot find ledger entry at or before timestamp: ${new Date(target.timestamp).toISOString()}`,
|
|
942
|
+
"HISTORY_STATE_NOT_FOUND",
|
|
943
|
+
404
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
function collectCommitCandidates(entries) {
|
|
947
|
+
const commits = [];
|
|
948
|
+
const seen = /* @__PURE__ */ new Set();
|
|
949
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
950
|
+
const entry = entries[index];
|
|
951
|
+
const commit = entry.source === "ai" ? entry.commit_sha : entry.parent_sha;
|
|
952
|
+
if (typeof commit !== "string" || commit.length === 0 || commit === "root" || seen.has(commit)) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
seen.add(commit);
|
|
956
|
+
commits.push(commit);
|
|
957
|
+
}
|
|
958
|
+
return commits;
|
|
959
|
+
}
|
|
960
|
+
async function tryReadAgentsMetaFromGit(projectRoot, commit) {
|
|
961
|
+
try {
|
|
962
|
+
const { stdout } = await execFileAsync(
|
|
963
|
+
"git",
|
|
964
|
+
["show", `${commit}:${AGENTS_META_GIT_PATH}`],
|
|
965
|
+
{
|
|
966
|
+
cwd: projectRoot,
|
|
967
|
+
encoding: "utf8",
|
|
968
|
+
maxBuffer: 1024 * 1024
|
|
969
|
+
}
|
|
970
|
+
);
|
|
971
|
+
return agentsMetaSchema2.parse(JSON.parse(stdout));
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (isRecoverableGitError(error)) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function buildLedgerFallbackMeta(entries) {
|
|
980
|
+
const nodes = entries.reduce((current, entry) => {
|
|
981
|
+
const hashBase = entry.source === "ai" ? entry.commit_sha ?? entry.id : entry.parent_sha;
|
|
982
|
+
for (const affectedPath of entry.affected_paths) {
|
|
983
|
+
current[affectedPath] = {
|
|
984
|
+
file: affectedPath,
|
|
985
|
+
scope_glob: affectedPath,
|
|
986
|
+
deps: [],
|
|
987
|
+
priority: "medium",
|
|
988
|
+
hash: `replayed:${hashBase ?? entry.id}`
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
return current;
|
|
992
|
+
}, {});
|
|
993
|
+
const lastEntry = entries.at(-1);
|
|
994
|
+
return {
|
|
995
|
+
revision: lastEntry?.source === "ai" ? lastEntry.commit_sha ?? `replayed:${lastEntry.id ?? entries.length}` : `replayed:${lastEntry?.id ?? entries.length}`,
|
|
996
|
+
nodes
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
function isRecoverableGitError(error) {
|
|
1000
|
+
if (!(error instanceof Error)) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
const nodeError = error;
|
|
1004
|
+
return nodeError.code === "ENOENT" || typeof nodeError.stderr === "string";
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/api/history.ts
|
|
1008
|
+
function registerHistoryApi(app, projectRoot) {
|
|
1009
|
+
app.get("/api/history/state", async (req, res) => {
|
|
1010
|
+
const validation = historyStateQuerySchema.safeParse({
|
|
1011
|
+
ledger_id: req.query.ledger_id,
|
|
1012
|
+
ts: req.query.at ?? req.query.ts
|
|
1013
|
+
});
|
|
1014
|
+
if (!validation.success) {
|
|
1015
|
+
sendValidationError(res, "Invalid history replay query parameters", validation.error.flatten());
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
const result = "ledger_id" in validation.data && validation.data.ledger_id !== void 0 ? await rehydrateAgentsMetaAt(projectRoot, { ledgerEntryId: validation.data.ledger_id }) : await rehydrateAgentsMetaAt(projectRoot, { timestamp: validation.data.ts });
|
|
1020
|
+
res.json(result);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
sendUnknownError(res, error);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
app.get("/api/replay", async (req, res) => {
|
|
1026
|
+
const validation = historyStateQuerySchema.safeParse({
|
|
1027
|
+
ledger_id: req.query.ledger_id,
|
|
1028
|
+
ts: req.query.at ?? req.query.ts
|
|
1029
|
+
});
|
|
1030
|
+
if (!validation.success) {
|
|
1031
|
+
sendValidationError(res, "Invalid history replay query parameters", validation.error.flatten());
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
const result = "ledger_id" in validation.data && validation.data.ledger_id !== void 0 ? await rehydrateAgentsMetaAt(projectRoot, { ledgerEntryId: validation.data.ledger_id }) : await rehydrateAgentsMetaAt(projectRoot, { timestamp: validation.data.ts });
|
|
1036
|
+
res.json(result);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
sendUnknownError(res, error);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/api/human-lock.ts
|
|
1044
|
+
import { humanLockApproveRequestSchema, humanLockFileParamsSchema } from "@fenglimg/fabric-shared";
|
|
1045
|
+
|
|
1046
|
+
// src/services/approve-human-lock.ts
|
|
1047
|
+
async function approveHumanLock(projectRoot, input) {
|
|
1048
|
+
assertPathWithinProjectRoot(projectRoot, input.file);
|
|
1049
|
+
const document = await readHumanLockDocument(projectRoot);
|
|
1050
|
+
const index = document.locked.findIndex(
|
|
1051
|
+
(entry) => entry.file === input.file && entry.start_line === input.start_line && entry.end_line === input.end_line
|
|
1052
|
+
);
|
|
1053
|
+
if (index === -1) {
|
|
1054
|
+
throw new Error(`Cannot find human lock entry: ${input.file}:${input.start_line}-${input.end_line}`);
|
|
1055
|
+
}
|
|
1056
|
+
const currentEntry = document.locked[index];
|
|
1057
|
+
if (currentEntry === void 0) {
|
|
1058
|
+
throw new Error(`Cannot find human lock entry: ${input.file}:${input.start_line}-${input.end_line}`);
|
|
1059
|
+
}
|
|
1060
|
+
const nextEntry = {
|
|
1061
|
+
...currentEntry,
|
|
1062
|
+
hash: input.new_hash
|
|
1063
|
+
};
|
|
1064
|
+
if (currentEntry.hash === input.new_hash) {
|
|
1065
|
+
const currentHash2 = await hashHumanLockedContent(projectRoot, nextEntry);
|
|
1066
|
+
return {
|
|
1067
|
+
updated: false,
|
|
1068
|
+
entry: {
|
|
1069
|
+
...nextEntry,
|
|
1070
|
+
drift: currentHash2 !== nextEntry.hash,
|
|
1071
|
+
current_hash: currentHash2
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
const nextLocked = document.locked.slice();
|
|
1076
|
+
nextLocked[index] = nextEntry;
|
|
1077
|
+
const nextRawObject = {
|
|
1078
|
+
...document.rawObject,
|
|
1079
|
+
locked: nextLocked
|
|
1080
|
+
};
|
|
1081
|
+
await atomicWriteText(document.path, `${JSON.stringify(nextRawObject, null, 2)}
|
|
1082
|
+
`);
|
|
1083
|
+
const currentHash = await hashHumanLockedContent(projectRoot, nextEntry);
|
|
1084
|
+
const ledgerEntry = await appendLedgerEntry(projectRoot, createApproveLedgerEntry(input));
|
|
1085
|
+
return {
|
|
1086
|
+
updated: true,
|
|
1087
|
+
entry: {
|
|
1088
|
+
...nextEntry,
|
|
1089
|
+
drift: currentHash !== nextEntry.hash,
|
|
1090
|
+
current_hash: currentHash
|
|
1091
|
+
},
|
|
1092
|
+
ledger_entry: ledgerEntry
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function createApproveLedgerEntry(input) {
|
|
1096
|
+
return {
|
|
1097
|
+
ts: Date.now(),
|
|
1098
|
+
source: "human",
|
|
1099
|
+
parent_sha: "human-lock:approve",
|
|
1100
|
+
intent: `approve human lock ${input.file}:${input.start_line}-${input.end_line}`,
|
|
1101
|
+
affected_paths: [input.file, ".fabric/human-lock.json"],
|
|
1102
|
+
diff_stat: `updated approved hash to ${input.new_hash}`
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/api/human-lock.ts
|
|
1107
|
+
function registerHumanLockApi(app, projectRoot) {
|
|
1108
|
+
app.get("/api/human-lock", async (_req, res) => {
|
|
1109
|
+
try {
|
|
1110
|
+
readAgentsMeta(projectRoot);
|
|
1111
|
+
res.json(await readHumanLock(projectRoot));
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
sendUnknownError(res, error);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
app.get(/^\/api\/human-lock\/(.+)$/, async (req, res) => {
|
|
1117
|
+
const rawFile = typeof req.params[0] === "string" ? decodeURIComponent(req.params[0]) : "";
|
|
1118
|
+
const validation = humanLockFileParamsSchema.safeParse({
|
|
1119
|
+
file: rawFile
|
|
1120
|
+
});
|
|
1121
|
+
if (!validation.success) {
|
|
1122
|
+
sendValidationError(res, "Invalid human-lock file path", validation.error.flatten());
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
try {
|
|
1126
|
+
readAgentsMeta(projectRoot);
|
|
1127
|
+
const entry = await readHumanLockEntry(projectRoot, validation.data.file);
|
|
1128
|
+
if (entry === null) {
|
|
1129
|
+
sendError(
|
|
1130
|
+
res,
|
|
1131
|
+
404,
|
|
1132
|
+
"HUMAN_LOCK_ENTRY_NOT_FOUND",
|
|
1133
|
+
`Cannot find human lock entry: ${validation.data.file}`
|
|
1134
|
+
);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
res.json(entry);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
sendUnknownError(res, error);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
app.post("/api/human-lock/approve", async (req, res) => {
|
|
1143
|
+
const validation = humanLockApproveRequestSchema.safeParse(req.body);
|
|
1144
|
+
if (!validation.success) {
|
|
1145
|
+
sendValidationError(res, "Invalid human-lock approval payload", validation.error.flatten());
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
readAgentsMeta(projectRoot);
|
|
1150
|
+
res.json(await approveHumanLock(projectRoot, validation.data));
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
sendUnknownError(res, error);
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// src/api/intent.ts
|
|
1158
|
+
import { annotateIntentRequestSchema } from "@fenglimg/fabric-shared";
|
|
1159
|
+
|
|
1160
|
+
// src/services/annotate-intent.ts
|
|
1161
|
+
async function annotateIntent(projectRoot, input) {
|
|
1162
|
+
const entries = await readLedger(projectRoot);
|
|
1163
|
+
const parentEntry = entries.find((entry2) => entry2.id === input.ledger_entry_id);
|
|
1164
|
+
if (parentEntry === void 0) {
|
|
1165
|
+
throw new Error(`Cannot find ledger entry: ${input.ledger_entry_id}`);
|
|
1166
|
+
}
|
|
1167
|
+
const lastEntry = entries[entries.length - 1];
|
|
1168
|
+
if (lastEntry?.source === "human" && lastEntry.parent_ledger_entry_id === input.ledger_entry_id && lastEntry.annotation === input.annotation) {
|
|
1169
|
+
return {
|
|
1170
|
+
created: false,
|
|
1171
|
+
entry: lastEntry
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
const entry = await appendLedgerEntry(projectRoot, createAnnotationEntry(parentEntry, input));
|
|
1175
|
+
return {
|
|
1176
|
+
created: true,
|
|
1177
|
+
entry
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
function createAnnotationEntry(parentEntry, input) {
|
|
1181
|
+
return {
|
|
1182
|
+
ts: Date.now(),
|
|
1183
|
+
source: "human",
|
|
1184
|
+
parent_sha: input.ledger_entry_id,
|
|
1185
|
+
parent_ledger_entry_id: input.ledger_entry_id,
|
|
1186
|
+
intent: input.annotation,
|
|
1187
|
+
annotation: input.annotation,
|
|
1188
|
+
affected_paths: parentEntry.affected_paths,
|
|
1189
|
+
diff_stat: "annotation"
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// src/api/intent.ts
|
|
1194
|
+
function registerIntentApi(app, projectRoot) {
|
|
1195
|
+
app.post("/api/intent/annotate", async (req, res) => {
|
|
1196
|
+
const validation = annotateIntentRequestSchema.safeParse(req.body);
|
|
1197
|
+
if (!validation.success) {
|
|
1198
|
+
sendValidationError(res, "Invalid intent annotation payload", validation.error.flatten());
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
readAgentsMeta(projectRoot);
|
|
1203
|
+
const result = await annotateIntent(projectRoot, validation.data);
|
|
1204
|
+
res.status(result.created ? 201 : 200).json(result);
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
sendUnknownError(res, error);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/api/ledger.ts
|
|
1212
|
+
import { ledgerQuerySchema } from "@fenglimg/fabric-shared";
|
|
1213
|
+
function registerLedgerApi(app, projectRoot) {
|
|
1214
|
+
app.get("/api/ledger", async (req, res) => {
|
|
1215
|
+
const validation = ledgerQuerySchema.safeParse({
|
|
1216
|
+
source: req.query.source,
|
|
1217
|
+
since: req.query.since
|
|
1218
|
+
});
|
|
1219
|
+
if (!validation.success) {
|
|
1220
|
+
sendValidationError(res, "Invalid ledger query parameters", validation.error.flatten());
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
readAgentsMeta(projectRoot);
|
|
1225
|
+
res.json(await readLedger(projectRoot, validation.data));
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
sendUnknownError(res, error);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/api/rules.ts
|
|
1233
|
+
function registerRulesApi(app, projectRoot) {
|
|
1234
|
+
app.get("/api/rules", async (_req, res) => {
|
|
1235
|
+
try {
|
|
1236
|
+
res.json(readAgentsMeta(projectRoot));
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
sendUnknownError(res, error);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// src/api/scan.ts
|
|
1244
|
+
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
1245
|
+
import { isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2, sep } from "path";
|
|
1246
|
+
import { detectFramework as detectFramework2 } from "@fenglimg/fabric-shared";
|
|
1247
|
+
var DEFAULT_IGNORES = [
|
|
1248
|
+
"**/*.meta",
|
|
1249
|
+
"library/**",
|
|
1250
|
+
"temp/**",
|
|
1251
|
+
"build/**",
|
|
1252
|
+
"settings/**",
|
|
1253
|
+
"profiles/**",
|
|
1254
|
+
"node_modules/**",
|
|
1255
|
+
"dist/**",
|
|
1256
|
+
".git/**",
|
|
1257
|
+
".fabric/**"
|
|
1258
|
+
];
|
|
1259
|
+
function registerScanApi(app, projectRoot) {
|
|
1260
|
+
app.get("/api/scan", async (_req, res) => {
|
|
1261
|
+
try {
|
|
1262
|
+
res.json(createScanReport(projectRoot));
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
sendUnknownError(res, error);
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
function createScanReport(targetInput = process.cwd()) {
|
|
1269
|
+
const target = normalizeTarget2(targetInput);
|
|
1270
|
+
const framework = detectFramework2(target);
|
|
1271
|
+
const readmeQuality = getReadmeQuality(target);
|
|
1272
|
+
const hasContributing = existsSync2(join3(target, "CONTRIBUTING.md"));
|
|
1273
|
+
const hasExistingFabric = existsSync2(join3(target, "AGENTS.md")) || existsSync2(join3(target, ".fabric"));
|
|
1274
|
+
const walkResult = walkFiles(target, DEFAULT_IGNORES);
|
|
1275
|
+
return {
|
|
1276
|
+
target,
|
|
1277
|
+
framework,
|
|
1278
|
+
readmeQuality,
|
|
1279
|
+
hasContributing,
|
|
1280
|
+
fileCount: walkResult.fileCount,
|
|
1281
|
+
ignoredCount: walkResult.ignoredCount,
|
|
1282
|
+
hasExistingFabric,
|
|
1283
|
+
recommendations: buildRecommendations({
|
|
1284
|
+
framework,
|
|
1285
|
+
readmeQuality,
|
|
1286
|
+
hasContributing,
|
|
1287
|
+
hasExistingFabric
|
|
1288
|
+
})
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function normalizeTarget2(targetInput) {
|
|
1292
|
+
return isAbsolute2(targetInput) ? targetInput : resolve2(process.cwd(), targetInput);
|
|
1293
|
+
}
|
|
1294
|
+
function getReadmeQuality(target) {
|
|
1295
|
+
const readmePath = join3(target, "README.md");
|
|
1296
|
+
if (!existsSync2(readmePath)) {
|
|
1297
|
+
return "stub";
|
|
1298
|
+
}
|
|
1299
|
+
const wordCount = readFileSync2(readmePath, "utf8").trim().split(/\s+/).filter(Boolean).length;
|
|
1300
|
+
return wordCount >= 200 ? "ok" : "stub";
|
|
1301
|
+
}
|
|
1302
|
+
function walkFiles(root, ignorePatterns) {
|
|
1303
|
+
if (!existsSync2(root) || !statSync2(root).isDirectory()) {
|
|
1304
|
+
throw new Error(`Target must be an existing directory: ${root}`);
|
|
1305
|
+
}
|
|
1306
|
+
let fileCount = 0;
|
|
1307
|
+
let ignoredCount = 0;
|
|
1308
|
+
const stack = [root];
|
|
1309
|
+
while (stack.length > 0) {
|
|
1310
|
+
const current = stack.pop();
|
|
1311
|
+
if (current === void 0) {
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
for (const entry of readdirSync2(current, { withFileTypes: true })) {
|
|
1315
|
+
const absolutePath = join3(current, entry.name);
|
|
1316
|
+
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
1317
|
+
if (shouldIgnore(relativePath, entry.isDirectory(), ignorePatterns)) {
|
|
1318
|
+
ignoredCount += 1;
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
if (entry.isDirectory()) {
|
|
1322
|
+
stack.push(absolutePath);
|
|
1323
|
+
} else if (entry.isFile()) {
|
|
1324
|
+
fileCount += 1;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return { fileCount, ignoredCount };
|
|
1329
|
+
}
|
|
1330
|
+
function shouldIgnore(relativePath, isDirectory, ignorePatterns) {
|
|
1331
|
+
return ignorePatterns.some((pattern) => matchesIgnorePattern(relativePath, isDirectory, pattern));
|
|
1332
|
+
}
|
|
1333
|
+
function matchesIgnorePattern(relativePath, isDirectory, pattern) {
|
|
1334
|
+
const normalizedPattern = toPosixPath(pattern);
|
|
1335
|
+
if (normalizedPattern === "**/*.meta") {
|
|
1336
|
+
return relativePath.endsWith(".meta");
|
|
1337
|
+
}
|
|
1338
|
+
if (normalizedPattern.endsWith("/**")) {
|
|
1339
|
+
const directoryPrefix = normalizedPattern.slice(0, -3);
|
|
1340
|
+
return relativePath === directoryPrefix || relativePath.startsWith(`${directoryPrefix}/`) || isDirectory && `${relativePath}/` === directoryPrefix;
|
|
1341
|
+
}
|
|
1342
|
+
return relativePath === normalizedPattern;
|
|
1343
|
+
}
|
|
1344
|
+
function toPosixPath(path) {
|
|
1345
|
+
return path.split(sep).join("/");
|
|
1346
|
+
}
|
|
1347
|
+
function buildRecommendations(input) {
|
|
1348
|
+
const recommendations = [];
|
|
1349
|
+
if (!input.hasExistingFabric) {
|
|
1350
|
+
recommendations.push("L0: Run fab init to scaffold AGENTS.md with TODO markers.");
|
|
1351
|
+
}
|
|
1352
|
+
if (input.readmeQuality === "stub") {
|
|
1353
|
+
recommendations.push("L0: Expand README.md before promoting project facts into AGENTS.md references.");
|
|
1354
|
+
}
|
|
1355
|
+
if (!input.hasContributing) {
|
|
1356
|
+
recommendations.push("L0: Add CONTRIBUTING.md or leave an AGENTS.md TODO reference for contribution flow.");
|
|
1357
|
+
}
|
|
1358
|
+
if (input.framework.kind === "unknown") {
|
|
1359
|
+
recommendations.push("L1: Add tech-stack TODOs manually because no framework marker was detected.");
|
|
1360
|
+
} else {
|
|
1361
|
+
recommendations.push(`L1: Review ${input.framework.kind} directories for future scoped AGENTS.md files.`);
|
|
1362
|
+
}
|
|
1363
|
+
return recommendations;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/api/static.ts
|
|
1367
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1368
|
+
import { dirname, resolve as resolve3 } from "path";
|
|
1369
|
+
import { fileURLToPath } from "url";
|
|
1370
|
+
import express from "express";
|
|
1371
|
+
var DEFAULT_STATIC_DIR = resolve3(dirname(fileURLToPath(import.meta.url)), "static");
|
|
1372
|
+
function registerDashboardStatic(app, options = {}) {
|
|
1373
|
+
if (options.dev ?? process.env.NODE_ENV === "development") {
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const staticDir = resolve3(options.dashboardDistPath ?? DEFAULT_STATIC_DIR);
|
|
1377
|
+
const indexPath = resolve3(staticDir, "index.html");
|
|
1378
|
+
if (!existsSync3(indexPath)) {
|
|
1379
|
+
warnMissingDashboard(staticDir);
|
|
1380
|
+
app.get("/", (_req, res) => {
|
|
1381
|
+
res.status(404).json({
|
|
1382
|
+
error: {
|
|
1383
|
+
code: "DASHBOARD_DIST_MISSING",
|
|
1384
|
+
message: `Fabric dashboard dist was not found at ${staticDir}. Run pnpm --filter @fenglimg/fabric-dashboard build.`
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
app.use("/", express.static(staticDir, { index: "index.html", fallthrough: true }));
|
|
1391
|
+
app.get(/^\/(?!api(?:\/|$)|mcp(?:\/|$)|events(?:\/|$)).*/, (_req, res) => {
|
|
1392
|
+
res.sendFile(indexPath);
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
function warnMissingDashboard(staticDir) {
|
|
1396
|
+
process.stderr.write(
|
|
1397
|
+
`[fabric-server] dashboard dist missing at ${staticDir}; '/' will return 404 until dashboard assets are built.
|
|
1398
|
+
`
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// src/middleware/bearer-auth.ts
|
|
1403
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
1404
|
+
function createBearerAuthMiddleware(token) {
|
|
1405
|
+
const expectedDigest = hashToken(token);
|
|
1406
|
+
return function bearerAuthMiddleware(req, res, next) {
|
|
1407
|
+
const header = readAuthorizationHeader(req.headers.authorization);
|
|
1408
|
+
const providedToken = parseBearerToken(header);
|
|
1409
|
+
if (providedToken === void 0 || !tokensMatch(providedToken, expectedDigest)) {
|
|
1410
|
+
sendError(res, 401, "UNAUTHORIZED", "Bearer token required");
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
next();
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
function readAuthorizationHeader(value) {
|
|
1417
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1418
|
+
return value;
|
|
1419
|
+
}
|
|
1420
|
+
if (Array.isArray(value)) {
|
|
1421
|
+
return value.find((entry) => entry.length > 0);
|
|
1422
|
+
}
|
|
1423
|
+
return void 0;
|
|
1424
|
+
}
|
|
1425
|
+
function parseBearerToken(header) {
|
|
1426
|
+
if (header === void 0) {
|
|
1427
|
+
return void 0;
|
|
1428
|
+
}
|
|
1429
|
+
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
1430
|
+
return match?.[1];
|
|
1431
|
+
}
|
|
1432
|
+
function tokensMatch(token, expectedDigest) {
|
|
1433
|
+
return timingSafeEqual(hashToken(token), expectedDigest);
|
|
1434
|
+
}
|
|
1435
|
+
function hashToken(token) {
|
|
1436
|
+
return createHash3("sha256").update(token, "utf8").digest();
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// src/http.ts
|
|
1440
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
1441
|
+
var LEDGER_FILE = ".intent-ledger.jsonl";
|
|
1442
|
+
var JsonlEventStore = class {
|
|
1443
|
+
constructor(ledgerPath) {
|
|
1444
|
+
this.ledgerPath = ledgerPath;
|
|
1445
|
+
}
|
|
1446
|
+
ledgerPath;
|
|
1447
|
+
async storeEvent(streamId, message) {
|
|
1448
|
+
const eventId = randomUUID2();
|
|
1449
|
+
const entry = {
|
|
1450
|
+
kind: "mcp-event",
|
|
1451
|
+
eventId,
|
|
1452
|
+
streamId,
|
|
1453
|
+
message
|
|
1454
|
+
};
|
|
1455
|
+
await appendFile(this.ledgerPath, `${JSON.stringify(entry)}
|
|
1456
|
+
`, "utf8");
|
|
1457
|
+
return eventId;
|
|
1458
|
+
}
|
|
1459
|
+
async getStreamIdForEventId(eventId) {
|
|
1460
|
+
const events = await this.readEvents();
|
|
1461
|
+
return events.find((event) => event.eventId === eventId)?.streamId;
|
|
1462
|
+
}
|
|
1463
|
+
async replayEventsAfter(lastEventId, { send }) {
|
|
1464
|
+
const events = await this.readEvents();
|
|
1465
|
+
const startIndex = events.findIndex((event) => event.eventId === lastEventId);
|
|
1466
|
+
if (startIndex === -1) {
|
|
1467
|
+
throw new Error(`Unknown event ID: ${lastEventId}`);
|
|
1468
|
+
}
|
|
1469
|
+
const streamId = events[startIndex]?.streamId;
|
|
1470
|
+
if (streamId === void 0) {
|
|
1471
|
+
throw new Error(`Missing stream for event ID: ${lastEventId}`);
|
|
1472
|
+
}
|
|
1473
|
+
for (const event of events.slice(startIndex + 1)) {
|
|
1474
|
+
if (event.streamId !== streamId) {
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
await send(event.eventId, event.message);
|
|
1478
|
+
}
|
|
1479
|
+
return streamId;
|
|
1480
|
+
}
|
|
1481
|
+
async readEvents() {
|
|
1482
|
+
let raw;
|
|
1483
|
+
try {
|
|
1484
|
+
raw = await readFile3(this.ledgerPath, "utf8");
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
if (isNodeError2(error) && error.code === "ENOENT") {
|
|
1487
|
+
return [];
|
|
1488
|
+
}
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => parseStoredMcpEvent(line)).filter((event) => event !== null);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
function createFabricHttpApp(options) {
|
|
1495
|
+
const { projectRoot, host = DEFAULT_HOST, authToken, dashboardDistPath, dev } = options;
|
|
1496
|
+
const app = createMcpExpressApp({ host });
|
|
1497
|
+
const ledgerPath = join4(projectRoot, LEDGER_FILE);
|
|
1498
|
+
const eventStore = new JsonlEventStore(ledgerPath);
|
|
1499
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1500
|
+
process.env.FABRIC_PROJECT_ROOT = projectRoot;
|
|
1501
|
+
app.disable("x-powered-by");
|
|
1502
|
+
if (authToken !== void 0) {
|
|
1503
|
+
const bearerAuth = createBearerAuthMiddleware(authToken);
|
|
1504
|
+
app.use("/api", bearerAuth);
|
|
1505
|
+
app.use("/events", bearerAuth);
|
|
1506
|
+
}
|
|
1507
|
+
registerRulesApi(app, projectRoot);
|
|
1508
|
+
registerLedgerApi(app, projectRoot);
|
|
1509
|
+
registerHistoryApi(app, projectRoot);
|
|
1510
|
+
registerScanApi(app, projectRoot);
|
|
1511
|
+
registerDoctorApi(app, projectRoot);
|
|
1512
|
+
registerHumanLockApi(app, projectRoot);
|
|
1513
|
+
registerIntentApi(app, projectRoot);
|
|
1514
|
+
app.get("/events", createEventsHandler({ projectRoot }));
|
|
1515
|
+
app.all("/mcp", async (req, res) => {
|
|
1516
|
+
const sessionId = readHeader(req.headers["mcp-session-id"]);
|
|
1517
|
+
if (sessionId !== void 0) {
|
|
1518
|
+
const session2 = sessions.get(sessionId);
|
|
1519
|
+
if (session2 === void 0) {
|
|
1520
|
+
writeJsonRpcError(res, 404, -32001, "Session not found");
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
await session2.transport.handleRequest(req, res, req.body);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (!isInitializeRequest(req.body)) {
|
|
1527
|
+
writeJsonRpcError(res, 400, -32e3, "Bad Request: Mcp-Session-Id header is required");
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const session = await createSession(eventStore, sessions);
|
|
1531
|
+
await session.transport.handleRequest(req, res, req.body);
|
|
1532
|
+
});
|
|
1533
|
+
registerDashboardStatic(app, { dashboardDistPath, dev });
|
|
1534
|
+
return app;
|
|
1535
|
+
}
|
|
1536
|
+
async function createSession(eventStore, sessions) {
|
|
1537
|
+
const { createFabricServer } = await import("./index.js");
|
|
1538
|
+
const server = createFabricServer();
|
|
1539
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1540
|
+
sessionIdGenerator: randomUUID2,
|
|
1541
|
+
enableJsonResponse: true,
|
|
1542
|
+
eventStore,
|
|
1543
|
+
onsessioninitialized: async (sessionId) => {
|
|
1544
|
+
sessions.set(sessionId, { server, transport });
|
|
1545
|
+
},
|
|
1546
|
+
onsessionclosed: async (sessionId) => {
|
|
1547
|
+
sessions.delete(sessionId);
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
transport.onclose = () => {
|
|
1551
|
+
const sessionId = transport.sessionId;
|
|
1552
|
+
if (sessionId !== void 0) {
|
|
1553
|
+
sessions.delete(sessionId);
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
await server.connect(transport);
|
|
1557
|
+
return { server, transport };
|
|
1558
|
+
}
|
|
1559
|
+
function isInitializeRequest(body) {
|
|
1560
|
+
if (Array.isArray(body)) {
|
|
1561
|
+
return body.some((entry) => isInitializeMessage(entry));
|
|
1562
|
+
}
|
|
1563
|
+
return isInitializeMessage(body);
|
|
1564
|
+
}
|
|
1565
|
+
function isInitializeMessage(value) {
|
|
1566
|
+
return value !== null && typeof value === "object" && "jsonrpc" in value && "method" in value && value.jsonrpc === "2.0" && value.method === "initialize";
|
|
1567
|
+
}
|
|
1568
|
+
function parseStoredMcpEvent(line) {
|
|
1569
|
+
try {
|
|
1570
|
+
const parsed = JSON.parse(line);
|
|
1571
|
+
if (parsed.kind !== "mcp-event" || typeof parsed.eventId !== "string" || typeof parsed.streamId !== "string" || parsed.message === void 0) {
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
return {
|
|
1575
|
+
kind: "mcp-event",
|
|
1576
|
+
eventId: parsed.eventId,
|
|
1577
|
+
streamId: parsed.streamId,
|
|
1578
|
+
message: parsed.message
|
|
1579
|
+
};
|
|
1580
|
+
} catch {
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
function readHeader(value) {
|
|
1585
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1586
|
+
return value;
|
|
1587
|
+
}
|
|
1588
|
+
if (Array.isArray(value)) {
|
|
1589
|
+
return value.find((entry) => entry.length > 0);
|
|
1590
|
+
}
|
|
1591
|
+
return void 0;
|
|
1592
|
+
}
|
|
1593
|
+
function writeJsonRpcError(res, status, code, message) {
|
|
1594
|
+
res.status(status).json({
|
|
1595
|
+
jsonrpc: "2.0",
|
|
1596
|
+
error: {
|
|
1597
|
+
code,
|
|
1598
|
+
message
|
|
1599
|
+
},
|
|
1600
|
+
id: null
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
function isNodeError2(error) {
|
|
1604
|
+
return error instanceof Error;
|
|
1605
|
+
}
|
|
1606
|
+
export {
|
|
1607
|
+
createFabricHttpApp
|
|
1608
|
+
};
|