@canonical/pragma-cli 0.27.1-experimental.0 → 0.29.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/pragma +0 -0
- package/package.json +16 -17
- package/src/config/readConfig.ts +38 -2
- package/src/config/types.ts +12 -1
- package/src/config/writeConfig.ts +12 -0
- package/src/constants.ts +12 -1
- package/src/domains/block/commands/index.ts +2 -1
- package/src/domains/block/commands/sample.ts +55 -0
- package/src/domains/block/formatters/index.ts +2 -1
- package/src/domains/block/formatters/sample.ts +21 -0
- package/src/domains/block/index.ts +3 -3
- package/src/domains/block/mcp/specs.ts +48 -0
- package/src/domains/block/operations/index.ts +2 -1
- package/src/domains/block/operations/lookup.ts +9 -0
- package/src/domains/block/operations/sample.ts +41 -0
- package/src/domains/block/orchestration/blockEmptyError.test.ts +26 -0
- package/src/domains/block/orchestration/resolveBlockLookup.ts +10 -3
- package/src/domains/config/commands/framework.test.ts +88 -0
- package/src/domains/config/commands/framework.ts +85 -0
- package/src/domains/config/commands/index.ts +2 -0
- package/src/domains/config/commands/trace.test.ts +148 -0
- package/src/domains/config/commands/trace.ts +80 -0
- package/src/domains/config/formatters/framework.ts +34 -0
- package/src/domains/config/formatters/index.ts +2 -0
- package/src/domains/config/formatters/trace.ts +28 -0
- package/src/domains/config/index.ts +15 -2
- package/src/domains/config/operations/index.ts +1 -0
- package/src/domains/config/operations/validateFramework.ts +24 -0
- package/src/domains/config/tasks/index.ts +2 -0
- package/src/domains/config/tasks/setChannel.ts +7 -1
- package/src/domains/config/tasks/setFramework.ts +40 -0
- package/src/domains/config/tasks/setTier.ts +7 -1
- package/src/domains/config/tasks/setTrace.ts +38 -0
- package/src/domains/doctor/operations/checks/checkKeStore.ts +5 -5
- package/src/domains/doctor/operations/checks/checkPackageRefs.ts +35 -41
- package/src/domains/doctor/operations/checks.test.ts +4 -1
- package/src/domains/doctor/operations/runChecks.test.ts +72 -0
- package/src/domains/doctor/operations/runChecks.ts +48 -12
- package/src/domains/graphql/commands/build.test.ts +194 -0
- package/src/domains/graphql/commands/build.ts +125 -0
- package/src/domains/graphql/commands/check.test.ts +139 -0
- package/src/domains/graphql/commands/check.ts +87 -0
- package/src/domains/graphql/commands/index.ts +4 -0
- package/src/domains/graphql/formatters/index.ts +3 -0
- package/src/domains/graphql/formatters/report.ts +78 -0
- package/src/domains/graphql/helpers/index.ts +4 -0
- package/src/domains/graphql/helpers/parsePrefixes.test.ts +44 -0
- package/src/domains/graphql/helpers/parsePrefixes.ts +38 -0
- package/src/domains/graphql/helpers/resolveSourceFiles.test.ts +56 -0
- package/src/domains/graphql/helpers/resolveSourceFiles.ts +45 -0
- package/src/domains/graphql/index.ts +21 -0
- package/src/domains/graphql/operations/compileSchema.test.ts +119 -0
- package/src/domains/graphql/operations/compileSchema.ts +109 -0
- package/src/domains/graphql/operations/index.ts +7 -0
- package/src/domains/graphql/types.ts +29 -0
- package/src/domains/info/operations/collectInfo.test.ts +2 -2
- package/src/domains/info/operations/collectInfo.ts +4 -4
- package/src/domains/llm/capabilities.test.ts +91 -0
- package/src/domains/llm/commands/capabilities.ts +28 -0
- package/src/domains/llm/commands/index.ts +1 -0
- package/src/domains/llm/data/commandReference.ts +90 -18
- package/src/domains/llm/data/decisionTrees.ts +15 -13
- package/src/domains/llm/data/index.ts +2 -0
- package/src/domains/llm/data/toolCatalog.ts +184 -0
- package/src/domains/llm/formatters/capabilities.ts +50 -0
- package/src/domains/llm/formatters/index.ts +1 -0
- package/src/domains/llm/index.ts +3 -2
- package/src/domains/llm/mcp/index.ts +1 -1
- package/src/domains/llm/mcp/specs.ts +59 -52
- package/src/domains/llm/types.ts +40 -0
- package/src/domains/modifier/commands/index.ts +2 -1
- package/src/domains/modifier/commands/sample.ts +53 -0
- package/src/domains/modifier/formatters/index.ts +2 -1
- package/src/domains/modifier/formatters/sample.ts +15 -0
- package/src/domains/modifier/index.ts +7 -3
- package/src/domains/modifier/mcp/specs.ts +47 -0
- package/src/domains/modifier/operations/index.ts +2 -1
- package/src/domains/modifier/operations/lookup.ts +9 -0
- package/src/domains/modifier/operations/sample.ts +32 -0
- package/src/domains/modifier/orchestration/modifierEmptyError.test.ts +26 -0
- package/src/domains/modifier/orchestration/modifierEmptyError.ts +7 -2
- package/src/domains/modifier/orchestration/resolveModifierLookup.ts +14 -2
- package/src/domains/refs/operations/index.ts +1 -1
- package/src/domains/refs/operations/paths.ts +29 -1
- package/src/domains/refs/operations/updateRefs.test.ts +6 -3
- package/src/domains/refs/operations/updateRefs.ts +3 -2
- package/src/domains/shared/bootStore.ts +88 -49
- package/src/domains/shared/contracts.ts +1 -0
- package/src/domains/shared/emptyErrorRecovery.test.ts +109 -0
- package/src/domains/shared/loaders/bundledLoader.ts +108 -0
- package/src/domains/shared/loaders/gitLoader.ts +31 -0
- package/src/domains/shared/loaders/index.ts +6 -0
- package/src/domains/shared/loaders/localLoader.ts +73 -0
- package/src/domains/shared/loaders/readPackageDir.ts +112 -0
- package/src/domains/shared/lookupMany.ts +9 -1
- package/src/domains/shared/packages.ts +21 -120
- package/src/domains/shared/pickRandom.ts +22 -0
- package/src/domains/shared/runtime.ts +6 -3
- package/src/domains/shared/sampleFormatters.ts +47 -0
- package/src/domains/shared/semanticPackage.ts +96 -0
- package/src/domains/shared/suggestions/detectCrossDomain.test.ts +43 -0
- package/src/domains/shared/suggestions/detectCrossDomain.ts +116 -0
- package/src/domains/shared/suggestions/expandGlob.test.ts +92 -0
- package/src/domains/shared/suggestions/expandGlob.ts +51 -0
- package/src/domains/shared/suggestions/expandLookupQueries.ts +57 -0
- package/src/domains/shared/suggestions/index.ts +7 -0
- package/src/domains/shared/suggestions/listDomainNames.ts +61 -0
- package/src/domains/shared/suggestions/suggestNames.test.ts +70 -0
- package/src/domains/shared/suggestions/suggestNames.ts +97 -0
- package/src/domains/shared/types/index.ts +1 -0
- package/src/domains/shared/types/runtime.ts +4 -1
- package/src/domains/shared/types/sample.ts +17 -0
- package/src/domains/skill/helpers/resolveSkillSources.ts +49 -16
- package/src/domains/standard/commands/index.ts +2 -1
- package/src/domains/standard/commands/sample.ts +57 -0
- package/src/domains/standard/formatters/index.ts +2 -1
- package/src/domains/standard/formatters/sample.ts +15 -0
- package/src/domains/standard/index.ts +8 -1
- package/src/domains/standard/mcp/specs.ts +48 -1
- package/src/domains/standard/operations/index.ts +2 -1
- package/src/domains/standard/operations/lookup.ts +9 -0
- package/src/domains/standard/operations/sample.ts +35 -0
- package/src/domains/standard/orchestration/resolveStandardLookup.ts +14 -2
- package/src/domains/standard/orchestration/standardEmptyError.test.ts +33 -0
- package/src/domains/standard/orchestration/standardEmptyError.ts +16 -6
- package/src/domains/token/commands/index.ts +2 -1
- package/src/domains/token/commands/sample.ts +53 -0
- package/src/domains/token/formatters/index.ts +2 -1
- package/src/domains/token/formatters/sample.ts +16 -0
- package/src/domains/token/index.ts +8 -1
- package/src/domains/token/mcp/specs.ts +47 -1
- package/src/domains/token/operations/index.ts +2 -1
- package/src/domains/token/operations/lookup.ts +9 -0
- package/src/domains/token/operations/sample.ts +32 -0
- package/src/domains/token/orchestration/resolveTokenLookup.ts +14 -2
- package/src/domains/token/orchestration/tokenEmptyError.test.ts +33 -0
- package/src/domains/token/orchestration/tokenEmptyError.ts +5 -2
- package/src/domains/trace/commands/clear.ts +71 -0
- package/src/domains/trace/commands/index.ts +5 -0
- package/src/domains/trace/commands/sessions.ts +32 -0
- package/src/domains/trace/commands/show.ts +145 -0
- package/src/domains/trace/formatters/index.ts +9 -0
- package/src/domains/trace/formatters/sessions.ts +80 -0
- package/src/domains/trace/formatters/show.ts +122 -0
- package/src/domains/trace/index.ts +17 -0
- package/src/domains/trace/operations/index.ts +8 -0
- package/src/domains/trace/operations/listSessions.test.ts +55 -0
- package/src/domains/trace/operations/listSessions.ts +52 -0
- package/src/domains/trace/operations/readTraceLog.test.ts +100 -0
- package/src/domains/trace/operations/readTraceLog.ts +104 -0
- package/src/domains/trace/tracePlugin.test.ts +171 -0
- package/src/domains/trace/tracePlugin.ts +159 -0
- package/src/domains/trace/types.ts +65 -0
- package/src/error/PragmaError.ts +15 -2
- package/src/error/types.ts +15 -1
- package/src/mcp/error/serializeErrorPayload.ts +7 -0
- package/src/mcp/error/types.ts +8 -0
- package/src/mcp/registerResources.test.ts +38 -16
- package/src/mcp/registerResources.ts +57 -8
- package/src/mcp/tools/registerTools.test.ts +22 -7
- package/src/mcp/utils/wrapTool.test.ts +1 -0
- package/src/pipeline/collectCommands.ts +7 -1
- package/src/pipeline/renderError.ts +15 -0
- package/src/pipeline/resolveCommandKind.ts +8 -1
- package/src/pipeline/runCli.ts +12 -1
- package/src/testing/createTestMcpClient.ts +1 -0
- package/src/testing/graphqlFixtures.ts +72 -0
- package/src/testing/index.ts +7 -0
- package/src/testing/integration/mcp-surface.test.ts +11 -5
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the `pragma trace` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* Shows query trace records from the latest (or specified) session.
|
|
5
|
+
* Supports `--follow` / `-f` for real-time tailing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type CommandDefinition,
|
|
10
|
+
createOutputResult,
|
|
11
|
+
} from "@canonical/cli-core";
|
|
12
|
+
import { traceDir } from "../../refs/operations/paths.js";
|
|
13
|
+
import { selectFormatter } from "../../shared/formatters.js";
|
|
14
|
+
import {
|
|
15
|
+
noSessionsFormatters,
|
|
16
|
+
sessionNotFoundFormatters,
|
|
17
|
+
showFormatters,
|
|
18
|
+
} from "../formatters/index.js";
|
|
19
|
+
import {
|
|
20
|
+
followTraceLog,
|
|
21
|
+
listSessions,
|
|
22
|
+
readTraceLog,
|
|
23
|
+
} from "../operations/index.js";
|
|
24
|
+
|
|
25
|
+
function formatOneLine(r: import("../types.js").TraceRecord): string {
|
|
26
|
+
const d = new Date(r.ts);
|
|
27
|
+
const time = [
|
|
28
|
+
String(d.getHours()).padStart(2, "0"),
|
|
29
|
+
String(d.getMinutes()).padStart(2, "0"),
|
|
30
|
+
String(d.getSeconds()).padStart(2, "0"),
|
|
31
|
+
].join(":");
|
|
32
|
+
const type = r.type.toUpperCase().padEnd(10);
|
|
33
|
+
// Match the non-follow formatter: ASK → true/false, CONSTRUCT → triples,
|
|
34
|
+
// SELECT (and anything else) → rows.
|
|
35
|
+
const plural = (n: number, word: string) =>
|
|
36
|
+
`${n} ${word}${n !== 1 ? "s" : ""}`;
|
|
37
|
+
const count = (
|
|
38
|
+
r.type === "ask"
|
|
39
|
+
? r.ask
|
|
40
|
+
? "true"
|
|
41
|
+
: "false"
|
|
42
|
+
: r.type === "construct"
|
|
43
|
+
? plural(r.count, "triple")
|
|
44
|
+
: plural(r.count, "row")
|
|
45
|
+
).padEnd(12);
|
|
46
|
+
const ms = `${r.ms}ms`.padEnd(9);
|
|
47
|
+
const q = r.q.replace(/\s+/g, " ").trim();
|
|
48
|
+
const query = q.length > 100 ? `${q.slice(0, 97)}...` : q;
|
|
49
|
+
return `#${String(r.seq).padEnd(4)} ${time} ${type}${count}${ms} ${r.qh} ${query}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const showCommand: CommandDefinition = {
|
|
53
|
+
path: ["trace"],
|
|
54
|
+
description: "View query access traces",
|
|
55
|
+
parameters: [
|
|
56
|
+
{
|
|
57
|
+
name: "follow",
|
|
58
|
+
description: "Tail the log in real time",
|
|
59
|
+
type: "boolean",
|
|
60
|
+
short: "f",
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "session",
|
|
65
|
+
description: "Session ID to view (default: latest)",
|
|
66
|
+
type: "string",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "limit",
|
|
70
|
+
description: "Maximum number of records to show",
|
|
71
|
+
type: "string",
|
|
72
|
+
default: "50",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
meta: {
|
|
76
|
+
examples: [
|
|
77
|
+
"pragma trace",
|
|
78
|
+
"pragma trace -f",
|
|
79
|
+
"pragma trace --limit 20",
|
|
80
|
+
"pragma trace --session 20260512-143022-a7f3",
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
async execute(params, ctx) {
|
|
84
|
+
const dir = traceDir();
|
|
85
|
+
const sessions = listSessions(dir);
|
|
86
|
+
|
|
87
|
+
if (sessions.length === 0) {
|
|
88
|
+
return createOutputResult(undefined, {
|
|
89
|
+
plain: selectFormatter(ctx, noSessionsFormatters),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sessionId =
|
|
94
|
+
typeof params.session === "string"
|
|
95
|
+
? params.session
|
|
96
|
+
: sessions[0]?.sessionId;
|
|
97
|
+
const target = sessions.find((s) => s.sessionId === sessionId);
|
|
98
|
+
|
|
99
|
+
if (!target) {
|
|
100
|
+
return createOutputResult(
|
|
101
|
+
{
|
|
102
|
+
requested: sessionId ?? "(unknown)",
|
|
103
|
+
available: sessions.map((s) => s.sessionId),
|
|
104
|
+
},
|
|
105
|
+
{ plain: selectFormatter(ctx, sessionNotFoundFormatters) },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Follow mode — stream new records to stdout and block
|
|
110
|
+
if (params.follow === true) {
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
`Following session ${target.sessionId} (ctrl+c to stop)\n\n`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const stop = followTraceLog(target.path, (record) => {
|
|
116
|
+
process.stdout.write(`${formatOneLine(record)}\n`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Block until interrupted
|
|
120
|
+
await new Promise<void>((resolve) => {
|
|
121
|
+
const handler = () => {
|
|
122
|
+
stop();
|
|
123
|
+
resolve();
|
|
124
|
+
};
|
|
125
|
+
process.once("SIGINT", handler);
|
|
126
|
+
process.once("SIGTERM", handler);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return createOutputResult("", { plain: () => "" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Normal mode — read and format
|
|
133
|
+
const limit =
|
|
134
|
+
typeof params.limit === "string" ? Number.parseInt(params.limit, 10) : 50;
|
|
135
|
+
|
|
136
|
+
const records = readTraceLog({ path: target.path, limit });
|
|
137
|
+
const data = { sessionId: target.sessionId, records };
|
|
138
|
+
|
|
139
|
+
return createOutputResult(data, {
|
|
140
|
+
plain: selectFormatter(ctx, showFormatters),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export default showCommand;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-mode formatter for `pragma trace sessions` output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Formatters } from "../../shared/formatters.js";
|
|
6
|
+
import type { TraceSession } from "../types.js";
|
|
7
|
+
|
|
8
|
+
function formatSize(bytes: number): string {
|
|
9
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
10
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
11
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatAge(date: Date): string {
|
|
15
|
+
const diffMs = Date.now() - date.getTime();
|
|
16
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
17
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
18
|
+
const minutes = Math.floor(seconds / 60);
|
|
19
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
20
|
+
const hours = Math.floor(minutes / 60);
|
|
21
|
+
if (hours < 24) return `${hours}h ago`;
|
|
22
|
+
const days = Math.floor(hours / 24);
|
|
23
|
+
return `${days}d ago`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const formatters: Formatters<TraceSession[]> = {
|
|
27
|
+
plain: (sessions) => {
|
|
28
|
+
if (sessions.length === 0) {
|
|
29
|
+
return "No trace sessions found.";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
const hdr = {
|
|
34
|
+
sid: "SESSION",
|
|
35
|
+
queries: "QUERIES",
|
|
36
|
+
size: "SIZE",
|
|
37
|
+
age: "AGE",
|
|
38
|
+
};
|
|
39
|
+
const sidW = Math.max(
|
|
40
|
+
hdr.sid.length,
|
|
41
|
+
...sessions.map((s) => s.sessionId.length),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
lines.push(
|
|
45
|
+
`${hdr.sid.padEnd(sidW)} ${hdr.queries.padStart(7)} ${hdr.size.padStart(8)} ${hdr.age}`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
for (const s of sessions) {
|
|
49
|
+
lines.push(
|
|
50
|
+
`${s.sessionId.padEnd(sidW)} ${String(s.queryCount).padStart(7)} ${formatSize(s.sizeBytes).padStart(8)} ${formatAge(s.createdAt)}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
llm: (sessions) => {
|
|
58
|
+
if (sessions.length === 0) {
|
|
59
|
+
return "No trace sessions found.";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
lines.push("## Trace Sessions");
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push("| Session | Queries | Size | Age |");
|
|
66
|
+
lines.push("|---------|---------|------|-----|");
|
|
67
|
+
|
|
68
|
+
for (const s of sessions) {
|
|
69
|
+
lines.push(
|
|
70
|
+
`| ${s.sessionId} | ${s.queryCount} | ${formatSize(s.sizeBytes)} | ${formatAge(s.createdAt)} |`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
json: (sessions) => JSON.stringify(sessions, null, 2),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default formatters;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-mode formatter for `pragma trace` output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Formatters } from "../../shared/formatters.js";
|
|
6
|
+
import type { TraceRecord } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export interface ShowData {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
records: TraceRecord[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatTime(isoString: string): string {
|
|
14
|
+
const d = new Date(isoString);
|
|
15
|
+
return [
|
|
16
|
+
String(d.getHours()).padStart(2, "0"),
|
|
17
|
+
String(d.getMinutes()).padStart(2, "0"),
|
|
18
|
+
String(d.getSeconds()).padStart(2, "0"),
|
|
19
|
+
].join(":");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatResultSummary(record: TraceRecord): string {
|
|
23
|
+
switch (record.type) {
|
|
24
|
+
case "select":
|
|
25
|
+
return `${record.count} row${record.count !== 1 ? "s" : ""}`;
|
|
26
|
+
case "construct":
|
|
27
|
+
return `${record.count} triple${record.count !== 1 ? "s" : ""}`;
|
|
28
|
+
case "ask":
|
|
29
|
+
return record.ask ? "true" : "false";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function totalDuration(records: TraceRecord[]): string {
|
|
34
|
+
const total = records.reduce((sum, r) => sum + r.ms, 0);
|
|
35
|
+
if (total < 1000) return `${Math.round(total)}ms`;
|
|
36
|
+
return `${(total / 1000).toFixed(1)}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncateQuery(query: string, maxLen = 120): string {
|
|
40
|
+
// Collapse whitespace for display
|
|
41
|
+
const collapsed = query.replace(/\s+/g, " ").trim();
|
|
42
|
+
if (collapsed.length <= maxLen) return collapsed;
|
|
43
|
+
return `${collapsed.slice(0, maxLen - 3)}...`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const formatters: Formatters<ShowData> = {
|
|
47
|
+
plain: ({ sessionId, records }) => {
|
|
48
|
+
if (records.length === 0) {
|
|
49
|
+
return `Session ${sessionId} (no queries)`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
lines.push(
|
|
54
|
+
`Session ${sessionId} (${records.length} quer${records.length === 1 ? "y" : "ies"}, ${totalDuration(records)})`,
|
|
55
|
+
);
|
|
56
|
+
lines.push("");
|
|
57
|
+
|
|
58
|
+
for (const r of records) {
|
|
59
|
+
const seqStr = `#${r.seq}`.padEnd(5);
|
|
60
|
+
const time = formatTime(r.ts);
|
|
61
|
+
const type = r.type.toUpperCase().padEnd(10);
|
|
62
|
+
const summary = formatResultSummary(r).padEnd(12);
|
|
63
|
+
const ms = `${r.ms}ms`.padEnd(9);
|
|
64
|
+
|
|
65
|
+
lines.push(`${seqStr} ${time} ${type}${summary}${ms} ${r.qh}`);
|
|
66
|
+
lines.push(` ${truncateQuery(r.q)}`);
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
llm: ({ sessionId, records }) => {
|
|
74
|
+
if (records.length === 0) {
|
|
75
|
+
return `## Trace: ${sessionId}\n\nNo queries recorded.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
lines.push(
|
|
80
|
+
`## Trace: ${sessionId} (${records.length} queries, ${totalDuration(records)})`,
|
|
81
|
+
);
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push("| # | Type | Count | ms | Hash | Query |");
|
|
84
|
+
lines.push("|---|------|-------|----|------|-------|");
|
|
85
|
+
|
|
86
|
+
for (const r of records) {
|
|
87
|
+
lines.push(
|
|
88
|
+
`| ${r.seq} | ${r.type} | ${formatResultSummary(r)} | ${r.ms} | ${r.qh} | \`${truncateQuery(r.q, 80)}\` |`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
json: ({ sessionId, records }) =>
|
|
96
|
+
JSON.stringify({ sessionId, records }, null, 2),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Formatters for the "no trace sessions exist yet" state (all three modes). */
|
|
100
|
+
export const noSessionsFormatters: Formatters<void> = {
|
|
101
|
+
plain: () =>
|
|
102
|
+
"No trace sessions found.\n\nEnable tracing: PRAGMA_TRACE=1 pragma <command>\n or: pragma config trace on",
|
|
103
|
+
llm: () =>
|
|
104
|
+
"No trace sessions found. Enable tracing with `PRAGMA_TRACE=1 pragma <command>` or `pragma config trace on`.",
|
|
105
|
+
json: () =>
|
|
106
|
+
JSON.stringify({ sessions: [], hint: "enable tracing to record queries" }),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Formatters for the "requested session does not exist" state (all three modes). */
|
|
110
|
+
export const sessionNotFoundFormatters: Formatters<{
|
|
111
|
+
requested: string;
|
|
112
|
+
available: string[];
|
|
113
|
+
}> = {
|
|
114
|
+
plain: ({ requested, available }) =>
|
|
115
|
+
`Session not found: ${requested}\n\nAvailable sessions: ${available.join(", ")}`,
|
|
116
|
+
llm: ({ requested, available }) =>
|
|
117
|
+
`Session not found: \`${requested}\`. Available sessions: ${available.map((s) => `\`${s}\``).join(", ")}.`,
|
|
118
|
+
json: ({ requested, available }) =>
|
|
119
|
+
JSON.stringify({ error: "session_not_found", requested, available }),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default formatters;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Trace domain — CLI commands for query access tracing.
|
|
3
|
+
*
|
|
4
|
+
* Commands: `pragma trace`, `pragma trace sessions`, `pragma trace clear`.
|
|
5
|
+
* Plugin: {@link createTracePlugin} (wired in bootStore when PRAGMA_TRACE=1).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CommandDefinition } from "@canonical/cli-core";
|
|
9
|
+
import {
|
|
10
|
+
clearCommand,
|
|
11
|
+
sessionsCommand,
|
|
12
|
+
showCommand,
|
|
13
|
+
} from "./commands/index.js";
|
|
14
|
+
|
|
15
|
+
export function commands(): CommandDefinition[] {
|
|
16
|
+
return [showCommand, sessionsCommand, clearCommand];
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { listSessions } from "./listSessions.js";
|
|
6
|
+
|
|
7
|
+
describe("listSessions", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), "pragma-trace-sessions-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns empty array for nonexistent directory", () => {
|
|
19
|
+
const result = listSessions("/nonexistent/path");
|
|
20
|
+
expect(result).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns empty array for empty directory", () => {
|
|
24
|
+
const result = listSessions(dir);
|
|
25
|
+
expect(result).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("lists .ndjson files as sessions", () => {
|
|
29
|
+
writeFileSync(join(dir, "20260512-143022-a7f3.ndjson"), '{"seq":0}\n');
|
|
30
|
+
writeFileSync(
|
|
31
|
+
join(dir, "20260512-140100-b8e2.ndjson"),
|
|
32
|
+
'{"seq":0}\n{"seq":1}\n',
|
|
33
|
+
);
|
|
34
|
+
// Non-ndjson file should be ignored
|
|
35
|
+
writeFileSync(join(dir, "notes.txt"), "ignore me");
|
|
36
|
+
|
|
37
|
+
const result = listSessions(dir);
|
|
38
|
+
|
|
39
|
+
expect(result).toHaveLength(2);
|
|
40
|
+
// Sorted newest first
|
|
41
|
+
expect(result[0].sessionId).toBe("20260512-143022-a7f3");
|
|
42
|
+
expect(result[1].sessionId).toBe("20260512-140100-b8e2");
|
|
43
|
+
expect(result[0].queryCount).toBe(1);
|
|
44
|
+
expect(result[1].queryCount).toBe(2);
|
|
45
|
+
expect(result[0].sizeBytes).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("ignores non-ndjson files", () => {
|
|
49
|
+
writeFileSync(join(dir, "readme.md"), "# traces");
|
|
50
|
+
writeFileSync(join(dir, ".hidden"), "secret");
|
|
51
|
+
|
|
52
|
+
const result = listSessions(dir);
|
|
53
|
+
expect(result).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List available trace sessions from the trace directory.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { TraceSession } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export function listSessions(traceDir: string): TraceSession[] {
|
|
10
|
+
let entries: string[];
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(traceDir);
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sessions: TraceSession[] = [];
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (!entry.endsWith(".ndjson")) continue;
|
|
21
|
+
|
|
22
|
+
const sessionId = entry.replace(/\.ndjson$/, "");
|
|
23
|
+
const filePath = join(traceDir, entry);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const stat = statSync(filePath);
|
|
27
|
+
// Count records by counting newline bytes on the raw Buffer. Each NDJSON
|
|
28
|
+
// record is one newline-terminated line, so this avoids decoding the
|
|
29
|
+
// whole file to a string and allocating a large split() array.
|
|
30
|
+
const buf = readFileSync(filePath);
|
|
31
|
+
let lineCount = 0;
|
|
32
|
+
for (let i = 0; i < buf.length; i++) {
|
|
33
|
+
if (buf[i] === 0x0a) lineCount++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
sessions.push({
|
|
37
|
+
sessionId,
|
|
38
|
+
path: filePath,
|
|
39
|
+
queryCount: lineCount,
|
|
40
|
+
sizeBytes: stat.size,
|
|
41
|
+
createdAt: stat.birthtime,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip files we can't stat/read
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sort by session ID descending (newest first — IDs are timestamp-based)
|
|
49
|
+
sessions.sort((a, b) => b.sessionId.localeCompare(a.sessionId));
|
|
50
|
+
|
|
51
|
+
return sessions;
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import type { TraceRecord } from "../types.js";
|
|
6
|
+
import { readTraceLog } from "./readTraceLog.js";
|
|
7
|
+
|
|
8
|
+
function makeRecord(seq: number): TraceRecord {
|
|
9
|
+
return {
|
|
10
|
+
ts: new Date().toISOString(),
|
|
11
|
+
sid: "test-session",
|
|
12
|
+
seq,
|
|
13
|
+
qh: "abcd1234",
|
|
14
|
+
q: `SELECT ?x WHERE { ?x ?p ?o } LIMIT ${seq + 1}`,
|
|
15
|
+
ms: seq * 1.5,
|
|
16
|
+
type: "select",
|
|
17
|
+
count: seq + 1,
|
|
18
|
+
vars: ["x"],
|
|
19
|
+
bindings: [{ x: `http://example.org/${seq}` }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("readTraceLog", () => {
|
|
24
|
+
let dir: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), "pragma-trace-read-"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("parses NDJSON records", () => {
|
|
35
|
+
const path = join(dir, "test.ndjson");
|
|
36
|
+
const records = [makeRecord(0), makeRecord(1), makeRecord(2)];
|
|
37
|
+
writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n"));
|
|
38
|
+
|
|
39
|
+
const result = readTraceLog({ path });
|
|
40
|
+
|
|
41
|
+
expect(result).toHaveLength(3);
|
|
42
|
+
expect(result[0].seq).toBe(0);
|
|
43
|
+
expect(result[1].seq).toBe(1);
|
|
44
|
+
expect(result[2].seq).toBe(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns empty array for missing file", () => {
|
|
48
|
+
const result = readTraceLog({ path: join(dir, "nonexistent.ndjson") });
|
|
49
|
+
expect(result).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips malformed lines", () => {
|
|
53
|
+
const path = join(dir, "malformed.ndjson");
|
|
54
|
+
writeFileSync(
|
|
55
|
+
path,
|
|
56
|
+
[
|
|
57
|
+
JSON.stringify(makeRecord(0)),
|
|
58
|
+
"not json",
|
|
59
|
+
JSON.stringify(makeRecord(1)),
|
|
60
|
+
].join("\n"),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const result = readTraceLog({ path });
|
|
64
|
+
|
|
65
|
+
expect(result).toHaveLength(2);
|
|
66
|
+
expect(result[0].seq).toBe(0);
|
|
67
|
+
expect(result[1].seq).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("respects limit (returns last N records)", () => {
|
|
71
|
+
const path = join(dir, "limited.ndjson");
|
|
72
|
+
const records = Array.from({ length: 10 }, (_, i) => makeRecord(i));
|
|
73
|
+
writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n"));
|
|
74
|
+
|
|
75
|
+
const result = readTraceLog({ path, limit: 3 });
|
|
76
|
+
|
|
77
|
+
expect(result).toHaveLength(3);
|
|
78
|
+
expect(result[0].seq).toBe(7);
|
|
79
|
+
expect(result[1].seq).toBe(8);
|
|
80
|
+
expect(result[2].seq).toBe(9);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns all records when limit exceeds count", () => {
|
|
84
|
+
const path = join(dir, "small.ndjson");
|
|
85
|
+
writeFileSync(path, JSON.stringify(makeRecord(0)));
|
|
86
|
+
|
|
87
|
+
const result = readTraceLog({ path, limit: 100 });
|
|
88
|
+
|
|
89
|
+
expect(result).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns empty array when limit is 0", () => {
|
|
93
|
+
const path = join(dir, "zero.ndjson");
|
|
94
|
+
writeFileSync(path, JSON.stringify(makeRecord(0)));
|
|
95
|
+
|
|
96
|
+
const result = readTraceLog({ path, limit: 0 });
|
|
97
|
+
|
|
98
|
+
expect(result).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and parse an NDJSON trace log file.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
6
|
+
import type { TraceRecord } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export interface ReadTraceLogOptions {
|
|
9
|
+
/** Absolute path to the NDJSON file. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Maximum number of records to return (from the tail). */
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse NDJSON lines into TraceRecord array, skipping malformed lines. */
|
|
16
|
+
function parseNdjson(content: string): TraceRecord[] {
|
|
17
|
+
const lines = content.split("\n").filter((line) => line.length > 0);
|
|
18
|
+
const records: TraceRecord[] = [];
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
try {
|
|
21
|
+
records.push(JSON.parse(line) as TraceRecord);
|
|
22
|
+
} catch {
|
|
23
|
+
// Skip malformed lines
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return records;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readTraceLog(options: ReadTraceLogOptions): TraceRecord[] {
|
|
30
|
+
const { path, limit } = options;
|
|
31
|
+
|
|
32
|
+
let content: string;
|
|
33
|
+
try {
|
|
34
|
+
content = readFileSync(path, "utf-8");
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const records = parseNdjson(content);
|
|
40
|
+
|
|
41
|
+
if (limit !== undefined) {
|
|
42
|
+
// A non-positive or non-finite limit returns no records. Guard explicitly:
|
|
43
|
+
// `slice(-0)` is `slice(0)` (the whole array) and `slice(NaN)` likewise
|
|
44
|
+
// returns everything, so the naive `slice(-limit)` mishandles both.
|
|
45
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return records.slice(-limit);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return records;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tail a trace log file, calling `onRecord` for each new record.
|
|
56
|
+
* Returns a cleanup function that stops the polling loop.
|
|
57
|
+
*/
|
|
58
|
+
export function followTraceLog(
|
|
59
|
+
path: string,
|
|
60
|
+
onRecord: (record: TraceRecord) => void,
|
|
61
|
+
): () => void {
|
|
62
|
+
let offset = 0;
|
|
63
|
+
let stopped = false;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const stat = statSync(path);
|
|
67
|
+
offset = stat.size;
|
|
68
|
+
} catch {
|
|
69
|
+
// File doesn't exist yet — start from 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const poll = () => {
|
|
73
|
+
if (stopped) return;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const stat = statSync(path);
|
|
77
|
+
if (stat.size > offset) {
|
|
78
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
79
|
+
const fd = openSync(path, "r");
|
|
80
|
+
readSync(fd, buf, 0, buf.length, offset);
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
|
|
83
|
+
const chunk = buf.toString("utf-8");
|
|
84
|
+
for (const record of parseNdjson(chunk)) {
|
|
85
|
+
onRecord(record);
|
|
86
|
+
}
|
|
87
|
+
offset = stat.size;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// File may not exist yet or be temporarily unavailable
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!stopped) {
|
|
94
|
+
setTimeout(poll, 250);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Start polling on next tick
|
|
99
|
+
setTimeout(poll, 0);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
stopped = true;
|
|
103
|
+
};
|
|
104
|
+
}
|