tana-mcp-codemode 0.1.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/README.md +344 -0
- package/package.json +54 -0
- package/src/api/client.ts +243 -0
- package/src/api/tana.ts +332 -0
- package/src/api/types.ts +207 -0
- package/src/generated/api.d.ts +1425 -0
- package/src/index.ts +90 -0
- package/src/prompts.ts +81 -0
- package/src/sandbox/executor.ts +270 -0
- package/src/sandbox/stdin.ts +69 -0
- package/src/sandbox/workflow.ts +197 -0
- package/src/storage/history.ts +202 -0
- package/src/types.ts +32 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tana MCP Server - Entry Point
|
|
4
|
+
*
|
|
5
|
+
* Codemode MCP server for Tana knowledge management.
|
|
6
|
+
* AI writes TypeScript code that executes against the Tana Local API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { createClient } from "./api/client";
|
|
14
|
+
import { createTanaAPI } from "./api/tana";
|
|
15
|
+
import { executeSandbox } from "./sandbox/executor";
|
|
16
|
+
import { cleanupOldRuns, initDb } from "./storage/history";
|
|
17
|
+
import { TOOL_DESCRIPTION } from "./prompts";
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
// Initialize database and cleanup old runs
|
|
21
|
+
initDb();
|
|
22
|
+
const cleaned = cleanupOldRuns(30);
|
|
23
|
+
if (cleaned > 0) {
|
|
24
|
+
console.error(`Cleaned up ${cleaned} old script runs`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Create Tana client and API
|
|
28
|
+
const client = createClient();
|
|
29
|
+
const tana = createTanaAPI(client);
|
|
30
|
+
|
|
31
|
+
// Create MCP server using the modern McpServer API
|
|
32
|
+
const server = new McpServer({
|
|
33
|
+
name: "tana-mcp-codemode",
|
|
34
|
+
version: "0.1.0",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Register the execute tool using registerTool (non-deprecated API)
|
|
38
|
+
server.registerTool(
|
|
39
|
+
"execute",
|
|
40
|
+
{
|
|
41
|
+
description: TOOL_DESCRIPTION,
|
|
42
|
+
inputSchema: {
|
|
43
|
+
code: z.string().describe("TypeScript code to execute"),
|
|
44
|
+
sessionId: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Optional session ID for grouping script runs"),
|
|
48
|
+
input: z
|
|
49
|
+
.string()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Data to pass to script via stdin() helper"),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
async ({ code, sessionId, input }) => {
|
|
55
|
+
const result = await executeSandbox(code, tana, sessionId, input);
|
|
56
|
+
|
|
57
|
+
// Format response
|
|
58
|
+
let responseText = "";
|
|
59
|
+
if (result.output) {
|
|
60
|
+
responseText += result.output;
|
|
61
|
+
}
|
|
62
|
+
if (result.error) {
|
|
63
|
+
responseText += responseText ? "\n\n" : "";
|
|
64
|
+
responseText += `Error: ${result.error}`;
|
|
65
|
+
}
|
|
66
|
+
responseText += `\n\n[Executed in ${result.durationMs}ms]`;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text" as const,
|
|
72
|
+
text: responseText,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
isError: !result.success,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Start server with stdio transport
|
|
81
|
+
const transport = new StdioServerTransport();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
|
|
84
|
+
console.error("Tana MCP server started");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
main().catch((error) => {
|
|
88
|
+
console.error("Fatal error:", error);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Description - Markdown Documentation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const TOOL_DESCRIPTION = `Execute TypeScript code to interact with Tana.
|
|
6
|
+
|
|
7
|
+
## APIs
|
|
8
|
+
|
|
9
|
+
tana.health() → { status }
|
|
10
|
+
tana.workspaces.list() → Workspace[] { id, name, homeNodeId }
|
|
11
|
+
|
|
12
|
+
### Nodes
|
|
13
|
+
tana.nodes.search(query, options?) → SearchResult[]
|
|
14
|
+
tana.nodes.read(nodeId, maxDepth?) → string (markdown)
|
|
15
|
+
tana.nodes.getChildren(nodeId, { limit?, offset? }) → { children, total, hasMore }
|
|
16
|
+
tana.nodes.edit({ nodeId, name?, description? }) → { success }
|
|
17
|
+
tana.nodes.trash(nodeId) → { success }
|
|
18
|
+
tana.nodes.check(nodeId) / uncheck(nodeId) → { success }
|
|
19
|
+
|
|
20
|
+
### Tags
|
|
21
|
+
tana.tags.list(workspaceId, limit?) → Tag[]
|
|
22
|
+
tana.tags.getSchema(tagId, includeEditInstructions?) → string
|
|
23
|
+
tana.tags.modify(nodeId, "add"|"remove", tagIds[])
|
|
24
|
+
tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
|
|
25
|
+
tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email"|"checkbox"|"user"|"instance"|"options", ... })
|
|
26
|
+
tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
27
|
+
|
|
28
|
+
### Fields
|
|
29
|
+
tana.fields.setOption(nodeId, attributeId, optionId)
|
|
30
|
+
tana.fields.setContent(nodeId, attributeId, content)
|
|
31
|
+
|
|
32
|
+
### Calendar
|
|
33
|
+
tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
|
|
34
|
+
|
|
35
|
+
### Import
|
|
36
|
+
tana.import(parentNodeId, tanaPasteContent) → { success, nodeIds? }
|
|
37
|
+
|
|
38
|
+
### Entry Points
|
|
39
|
+
inbox: \`\${workspaceId}_CAPTURE_INBOX\`
|
|
40
|
+
library: \`\${workspaceId}_STASH\`
|
|
41
|
+
|
|
42
|
+
### Helpers
|
|
43
|
+
stdin().json<T>() | .lines() | .text() | .hasInput()
|
|
44
|
+
workflow.start(msg) | .step(msg) | .progress(n, total, msg) | .complete(msg) | .abort(err)
|
|
45
|
+
|
|
46
|
+
## Edit (search-and-replace)
|
|
47
|
+
|
|
48
|
+
tana.nodes.edit({ nodeId, name: { old_string, new_string }, description: { old_string, new_string } })
|
|
49
|
+
Empty old_string matches absent field.
|
|
50
|
+
|
|
51
|
+
## Search Query Operators
|
|
52
|
+
|
|
53
|
+
{ textContains: string } | { textMatches: "/regex/i" }
|
|
54
|
+
{ hasType: tagId } | { field: { fieldId, stringValue?, numberValue?, nodeId?, state? } }
|
|
55
|
+
{ compare: { fieldId, operator: "gt"|"lt"|"eq", value, type } }
|
|
56
|
+
{ childOf: { nodeIds[], recursive?, includeRefs? } } | { ownedBy: { nodeId, recursive?, includeSelf? } }
|
|
57
|
+
{ linksTo: nodeIds[] }
|
|
58
|
+
{ is: "done"|"todo"|"template"|"entity"|"calendarNode"|"search"|"inLibrary" }
|
|
59
|
+
{ has: "tag"|"field"|"media"|"audio"|"video"|"image" }
|
|
60
|
+
{ created: { last: N } } | { edited: { last?, by?, since? } } | { done: { last: N } }
|
|
61
|
+
{ onDate: "YYYY-MM-DD" | { date, fieldId?, overlaps? } }
|
|
62
|
+
{ overdue: true } | { inLibrary: true }
|
|
63
|
+
{ and: [...] } | { or: [...] } | { not: {...} }
|
|
64
|
+
|
|
65
|
+
## Examples
|
|
66
|
+
|
|
67
|
+
Search: await tana.nodes.search({ and: [{ hasType: "tagId" }, { is: "todo" }] })
|
|
68
|
+
|
|
69
|
+
Import:
|
|
70
|
+
\`\`\`
|
|
71
|
+
await tana.import(parentNodeId, \`
|
|
72
|
+
- Node #[[^tagId]]
|
|
73
|
+
- [[^fieldId]]:: value
|
|
74
|
+
- [ ] Todo item
|
|
75
|
+
\`)
|
|
76
|
+
\`\`\`
|
|
77
|
+
|
|
78
|
+
## Output
|
|
79
|
+
|
|
80
|
+
console.log() to return data.
|
|
81
|
+
`;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox - Code Execution Engine
|
|
3
|
+
*
|
|
4
|
+
* Executes arbitrary TypeScript code with injected `tana` API object.
|
|
5
|
+
* Features:
|
|
6
|
+
* - AsyncFunction for top-level await support
|
|
7
|
+
* - 10s timeout via Promise.race
|
|
8
|
+
* - Duration tracking
|
|
9
|
+
* - Automatic history persistence with API call tracking
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SandboxResult } from "../types";
|
|
13
|
+
import type { TanaAPI } from "../api/tana";
|
|
14
|
+
import { saveScriptRun } from "../storage/history";
|
|
15
|
+
import { createStdinHelper } from "./stdin";
|
|
16
|
+
import { createWorkflowHelper } from "./workflow";
|
|
17
|
+
|
|
18
|
+
const EXECUTION_TIMEOUT = 10_000; // 10 seconds
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wraps the tana API to track which methods are called
|
|
22
|
+
*/
|
|
23
|
+
function createTrackedTanaAPI(
|
|
24
|
+
tana: TanaAPI,
|
|
25
|
+
tracker: { calls: string[]; nodeIds: Set<string>; workspaceId: string | null }
|
|
26
|
+
): TanaAPI {
|
|
27
|
+
const track = <T>(method: string, fn: () => Promise<T>): Promise<T> => {
|
|
28
|
+
tracker.calls.push(method);
|
|
29
|
+
return fn();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const trackNodeId = (id: string) => tracker.nodeIds.add(id);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
health: () => track("health", () => tana.health()),
|
|
36
|
+
|
|
37
|
+
workspaces: {
|
|
38
|
+
list: () => track("workspaces.list", () => tana.workspaces.list()),
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
nodes: {
|
|
42
|
+
search: (query, options) => {
|
|
43
|
+
if (options?.workspaceIds?.[0]) {
|
|
44
|
+
tracker.workspaceId = options.workspaceIds[0];
|
|
45
|
+
}
|
|
46
|
+
return track("nodes.search", () => tana.nodes.search(query, options));
|
|
47
|
+
},
|
|
48
|
+
read: (nodeId, maxDepth) => {
|
|
49
|
+
trackNodeId(nodeId);
|
|
50
|
+
return track("nodes.read", () => tana.nodes.read(nodeId, maxDepth));
|
|
51
|
+
},
|
|
52
|
+
getChildren: (nodeId, options) => {
|
|
53
|
+
trackNodeId(nodeId);
|
|
54
|
+
return track("nodes.getChildren", () => tana.nodes.getChildren(nodeId, options));
|
|
55
|
+
},
|
|
56
|
+
edit: (options) => {
|
|
57
|
+
trackNodeId(options.nodeId);
|
|
58
|
+
return track("nodes.edit", () => tana.nodes.edit(options));
|
|
59
|
+
},
|
|
60
|
+
trash: (nodeId) => {
|
|
61
|
+
trackNodeId(nodeId);
|
|
62
|
+
return track("nodes.trash", () => tana.nodes.trash(nodeId));
|
|
63
|
+
},
|
|
64
|
+
check: (nodeId) => {
|
|
65
|
+
trackNodeId(nodeId);
|
|
66
|
+
return track("nodes.check", () => tana.nodes.check(nodeId));
|
|
67
|
+
},
|
|
68
|
+
uncheck: (nodeId) => {
|
|
69
|
+
trackNodeId(nodeId);
|
|
70
|
+
return track("nodes.uncheck", () => tana.nodes.uncheck(nodeId));
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
tags: {
|
|
75
|
+
list: (workspaceId, limit) => {
|
|
76
|
+
tracker.workspaceId = workspaceId;
|
|
77
|
+
return track("tags.list", () => tana.tags.list(workspaceId, limit));
|
|
78
|
+
},
|
|
79
|
+
getSchema: (tagId, includeEditInstructions) =>
|
|
80
|
+
track("tags.getSchema", () => tana.tags.getSchema(tagId, includeEditInstructions)),
|
|
81
|
+
modify: (nodeId, action, tagIds) => {
|
|
82
|
+
trackNodeId(nodeId);
|
|
83
|
+
return track("tags.modify", () => tana.tags.modify(nodeId, action, tagIds));
|
|
84
|
+
},
|
|
85
|
+
create: (options) => {
|
|
86
|
+
tracker.workspaceId = options.workspaceId;
|
|
87
|
+
return track("tags.create", () => tana.tags.create(options));
|
|
88
|
+
},
|
|
89
|
+
addField: (options) =>
|
|
90
|
+
track("tags.addField", () => tana.tags.addField(options)),
|
|
91
|
+
setCheckbox: (options) =>
|
|
92
|
+
track("tags.setCheckbox", () => tana.tags.setCheckbox(options)),
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
fields: {
|
|
96
|
+
setOption: (nodeId, attributeId, optionId) => {
|
|
97
|
+
trackNodeId(nodeId);
|
|
98
|
+
return track("fields.setOption", () => tana.fields.setOption(nodeId, attributeId, optionId));
|
|
99
|
+
},
|
|
100
|
+
setContent: (nodeId, attributeId, content) => {
|
|
101
|
+
trackNodeId(nodeId);
|
|
102
|
+
return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
calendar: {
|
|
107
|
+
getOrCreate: (workspaceId, granularity, date) => {
|
|
108
|
+
tracker.workspaceId = workspaceId;
|
|
109
|
+
return track("calendar.getOrCreate", () =>
|
|
110
|
+
tana.calendar.getOrCreate(workspaceId, granularity, date)
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
import: (parentNodeId, content) => {
|
|
116
|
+
trackNodeId(parentNodeId);
|
|
117
|
+
return track("import", () => tana.import(parentNodeId, content));
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function executeSandbox(
|
|
123
|
+
code: string,
|
|
124
|
+
tana: TanaAPI,
|
|
125
|
+
sessionId?: string,
|
|
126
|
+
input?: string
|
|
127
|
+
): Promise<SandboxResult> {
|
|
128
|
+
const startTime = performance.now();
|
|
129
|
+
const logs: string[] = [];
|
|
130
|
+
|
|
131
|
+
// Track API usage
|
|
132
|
+
const tracker = {
|
|
133
|
+
calls: [] as string[],
|
|
134
|
+
nodeIds: new Set<string>(),
|
|
135
|
+
workspaceId: null as string | null,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Custom console that captures output
|
|
139
|
+
const sandboxConsole = {
|
|
140
|
+
log: (...args: unknown[]) => {
|
|
141
|
+
logs.push(args.map(formatArg).join(" "));
|
|
142
|
+
},
|
|
143
|
+
error: (...args: unknown[]) => {
|
|
144
|
+
logs.push(`[ERROR] ${args.map(formatArg).join(" ")}`);
|
|
145
|
+
},
|
|
146
|
+
warn: (...args: unknown[]) => {
|
|
147
|
+
logs.push(`[WARN] ${args.map(formatArg).join(" ")}`);
|
|
148
|
+
},
|
|
149
|
+
info: (...args: unknown[]) => {
|
|
150
|
+
logs.push(args.map(formatArg).join(" "));
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Create helpers for script execution
|
|
155
|
+
const stdin = createStdinHelper(input);
|
|
156
|
+
const effectiveSessionId = sessionId ?? crypto.randomUUID();
|
|
157
|
+
const workflow = createWorkflowHelper(effectiveSessionId);
|
|
158
|
+
|
|
159
|
+
// Wrap tana to track API calls
|
|
160
|
+
const trackedTana = createTrackedTanaAPI(tana, tracker);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Create async function to support top-level await
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
165
|
+
const AsyncFunction = Object.getPrototypeOf(
|
|
166
|
+
async function () {}
|
|
167
|
+
).constructor;
|
|
168
|
+
|
|
169
|
+
// Globals to shadow (will receive undefined to prevent accidental access)
|
|
170
|
+
// Note: This is defense-in-depth, not a security sandbox
|
|
171
|
+
const shadowedGlobals = [
|
|
172
|
+
"process",
|
|
173
|
+
"require",
|
|
174
|
+
"Bun",
|
|
175
|
+
"Deno",
|
|
176
|
+
"globalThis",
|
|
177
|
+
"global",
|
|
178
|
+
"eval",
|
|
179
|
+
"Function",
|
|
180
|
+
"__dirname",
|
|
181
|
+
"__filename",
|
|
182
|
+
"module",
|
|
183
|
+
"exports",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const fn = new AsyncFunction(
|
|
187
|
+
"tana",
|
|
188
|
+
"console",
|
|
189
|
+
"stdin",
|
|
190
|
+
"workflow",
|
|
191
|
+
...shadowedGlobals,
|
|
192
|
+
code
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Race between execution and timeout
|
|
196
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
reject(new Error(`Execution timed out after ${EXECUTION_TIMEOUT}ms`));
|
|
199
|
+
}, EXECUTION_TIMEOUT);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Pass undefined for all shadowed globals
|
|
203
|
+
const undefinedArgs = shadowedGlobals.map(() => undefined);
|
|
204
|
+
await Promise.race([
|
|
205
|
+
fn(trackedTana, sandboxConsole, stdin, workflow, ...undefinedArgs),
|
|
206
|
+
timeoutPromise,
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
210
|
+
const output = logs.join("\n");
|
|
211
|
+
|
|
212
|
+
// Fire-and-forget save to history
|
|
213
|
+
saveScriptRun({
|
|
214
|
+
script: code,
|
|
215
|
+
success: true,
|
|
216
|
+
output,
|
|
217
|
+
error: null,
|
|
218
|
+
durationMs,
|
|
219
|
+
sessionId: effectiveSessionId,
|
|
220
|
+
input: input ?? null,
|
|
221
|
+
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
222
|
+
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
223
|
+
workspaceId: tracker.workspaceId,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
success: true,
|
|
228
|
+
output,
|
|
229
|
+
durationMs,
|
|
230
|
+
};
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
233
|
+
const errorMessage =
|
|
234
|
+
error instanceof Error ? error.message : String(error);
|
|
235
|
+
const output = logs.join("\n");
|
|
236
|
+
|
|
237
|
+
// Fire-and-forget save to history
|
|
238
|
+
saveScriptRun({
|
|
239
|
+
script: code,
|
|
240
|
+
success: false,
|
|
241
|
+
output,
|
|
242
|
+
error: errorMessage,
|
|
243
|
+
durationMs,
|
|
244
|
+
sessionId: effectiveSessionId,
|
|
245
|
+
input: input ?? null,
|
|
246
|
+
apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
|
|
247
|
+
nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
|
|
248
|
+
workspaceId: tracker.workspaceId,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
success: false,
|
|
253
|
+
output,
|
|
254
|
+
error: errorMessage,
|
|
255
|
+
durationMs,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatArg(arg: unknown): string {
|
|
261
|
+
if (arg === null) return "null";
|
|
262
|
+
if (arg === undefined) return "undefined";
|
|
263
|
+
if (typeof arg === "string") return arg;
|
|
264
|
+
if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
|
|
265
|
+
try {
|
|
266
|
+
return JSON.stringify(arg, null, 2);
|
|
267
|
+
} catch {
|
|
268
|
+
return String(arg);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdin() Helper
|
|
3
|
+
*
|
|
4
|
+
* Provides convenient methods for handling input data passed to scripts.
|
|
5
|
+
* In MCP context, this handles data passed via the `input` parameter.
|
|
6
|
+
*
|
|
7
|
+
* Usage in scripts:
|
|
8
|
+
* const data = stdin().json(); // Parse as JSON
|
|
9
|
+
* const lines = stdin().lines(); // Split into lines
|
|
10
|
+
* const text = stdin().text(); // Raw trimmed text
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface StdinHelper {
|
|
14
|
+
/** Get raw input as trimmed string */
|
|
15
|
+
text(): string;
|
|
16
|
+
/** Split input into array of lines (empty lines filtered) */
|
|
17
|
+
lines(): string[];
|
|
18
|
+
/** Parse input as JSON (throws if invalid) */
|
|
19
|
+
json<T = unknown>(): T;
|
|
20
|
+
/** Parse input as JSON, return null if invalid */
|
|
21
|
+
jsonSafe<T = unknown>(): T | null;
|
|
22
|
+
/** Check if any input was provided */
|
|
23
|
+
hasInput(): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createStdinHelper(input: string | undefined): () => StdinHelper {
|
|
27
|
+
const rawInput = input ?? "";
|
|
28
|
+
|
|
29
|
+
return () => ({
|
|
30
|
+
text(): string {
|
|
31
|
+
return rawInput.trim();
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
lines(): string[] {
|
|
35
|
+
return rawInput
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter((line) => line.length > 0);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
json<T = unknown>(): T {
|
|
42
|
+
const text = rawInput.trim();
|
|
43
|
+
if (!text) {
|
|
44
|
+
throw new Error("stdin is empty - no JSON to parse");
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text) as T;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
jsonSafe<T = unknown>(): T | null {
|
|
56
|
+
const text = rawInput.trim();
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text) as T;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
hasInput(): boolean {
|
|
66
|
+
return rawInput.trim().length > 0;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Event Logging
|
|
3
|
+
*
|
|
4
|
+
* Tracks execution progress through multi-step operations.
|
|
5
|
+
* Provides timeline view instead of flat pass/fail history.
|
|
6
|
+
*
|
|
7
|
+
* Usage in scripts:
|
|
8
|
+
* workflow.start("Processing nodes");
|
|
9
|
+
* workflow.step("Fetching workspaces");
|
|
10
|
+
* workflow.step("Found 3 workspaces");
|
|
11
|
+
* workflow.complete("Done!");
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { initDb } from "../storage/history";
|
|
16
|
+
|
|
17
|
+
export type WorkflowEventType =
|
|
18
|
+
| "start"
|
|
19
|
+
| "step"
|
|
20
|
+
| "progress"
|
|
21
|
+
| "complete"
|
|
22
|
+
| "abort";
|
|
23
|
+
|
|
24
|
+
export interface WorkflowEvent {
|
|
25
|
+
id: number;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
eventType: WorkflowEventType;
|
|
29
|
+
message: string;
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WorkflowHelper {
|
|
34
|
+
/** Start a new workflow with description */
|
|
35
|
+
start(message: string): void;
|
|
36
|
+
/** Log an intermediate step */
|
|
37
|
+
step(message: string): void;
|
|
38
|
+
/** Log progress (e.g., "25/100 processed") */
|
|
39
|
+
progress(current: number, total: number, message?: string): void;
|
|
40
|
+
/** Mark workflow as successfully completed */
|
|
41
|
+
complete(message?: string): void;
|
|
42
|
+
/** Mark workflow as aborted/failed */
|
|
43
|
+
abort(reason: string): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let workflowTableInitialized = false;
|
|
47
|
+
|
|
48
|
+
function ensureWorkflowTable(db: Database): void {
|
|
49
|
+
if (workflowTableInitialized) return;
|
|
50
|
+
|
|
51
|
+
db.run(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
session_id TEXT NOT NULL,
|
|
55
|
+
timestamp INTEGER NOT NULL,
|
|
56
|
+
event_type TEXT NOT NULL,
|
|
57
|
+
message TEXT NOT NULL,
|
|
58
|
+
metadata TEXT
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
db.run(`
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_session
|
|
64
|
+
ON workflow_events(session_id, timestamp)
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
workflowTableInitialized = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveWorkflowEvent(
|
|
71
|
+
sessionId: string,
|
|
72
|
+
eventType: WorkflowEventType,
|
|
73
|
+
message: string,
|
|
74
|
+
metadata?: Record<string, unknown>
|
|
75
|
+
): void {
|
|
76
|
+
try {
|
|
77
|
+
const db = initDb();
|
|
78
|
+
ensureWorkflowTable(db);
|
|
79
|
+
|
|
80
|
+
const stmt = db.prepare(`
|
|
81
|
+
INSERT INTO workflow_events (session_id, timestamp, event_type, message, metadata)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?)
|
|
83
|
+
`);
|
|
84
|
+
stmt.run(
|
|
85
|
+
sessionId,
|
|
86
|
+
Date.now(),
|
|
87
|
+
eventType,
|
|
88
|
+
message,
|
|
89
|
+
metadata ? JSON.stringify(metadata) : null
|
|
90
|
+
);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Fire-and-forget - don't disrupt execution
|
|
93
|
+
console.error("Failed to save workflow event:", e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createWorkflowHelper(sessionId: string): WorkflowHelper {
|
|
98
|
+
return {
|
|
99
|
+
start(message: string): void {
|
|
100
|
+
saveWorkflowEvent(sessionId, "start", message);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
step(message: string): void {
|
|
104
|
+
saveWorkflowEvent(sessionId, "step", message);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
progress(current: number, total: number, message?: string): void {
|
|
108
|
+
const progressMsg = message
|
|
109
|
+
? `${message} (${current}/${total})`
|
|
110
|
+
: `${current}/${total}`;
|
|
111
|
+
saveWorkflowEvent(sessionId, "progress", progressMsg, { current, total });
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
complete(message?: string): void {
|
|
115
|
+
saveWorkflowEvent(sessionId, "complete", message ?? "Completed");
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
abort(reason: string): void {
|
|
119
|
+
saveWorkflowEvent(sessionId, "abort", reason);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getWorkflowEvents(
|
|
125
|
+
sessionId: string,
|
|
126
|
+
limit = 100
|
|
127
|
+
): WorkflowEvent[] {
|
|
128
|
+
try {
|
|
129
|
+
const db = initDb();
|
|
130
|
+
ensureWorkflowTable(db);
|
|
131
|
+
|
|
132
|
+
const stmt = db.prepare(`
|
|
133
|
+
SELECT
|
|
134
|
+
id,
|
|
135
|
+
session_id as sessionId,
|
|
136
|
+
timestamp,
|
|
137
|
+
event_type as eventType,
|
|
138
|
+
message,
|
|
139
|
+
metadata
|
|
140
|
+
FROM workflow_events
|
|
141
|
+
WHERE session_id = ?
|
|
142
|
+
ORDER BY timestamp ASC
|
|
143
|
+
LIMIT ?
|
|
144
|
+
`);
|
|
145
|
+
|
|
146
|
+
const rows = stmt.all(sessionId, limit) as Array<{
|
|
147
|
+
id: number;
|
|
148
|
+
sessionId: string;
|
|
149
|
+
timestamp: number;
|
|
150
|
+
eventType: WorkflowEventType;
|
|
151
|
+
message: string;
|
|
152
|
+
metadata: string | null;
|
|
153
|
+
}>;
|
|
154
|
+
|
|
155
|
+
return rows.map((row) => ({
|
|
156
|
+
...row,
|
|
157
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
158
|
+
}));
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getRecentWorkflows(limit = 20): Array<{
|
|
165
|
+
sessionId: string;
|
|
166
|
+
startTime: number;
|
|
167
|
+
eventCount: number;
|
|
168
|
+
lastEvent: string;
|
|
169
|
+
}> {
|
|
170
|
+
try {
|
|
171
|
+
const db = initDb();
|
|
172
|
+
ensureWorkflowTable(db);
|
|
173
|
+
|
|
174
|
+
const stmt = db.prepare(`
|
|
175
|
+
SELECT
|
|
176
|
+
session_id as sessionId,
|
|
177
|
+
MIN(timestamp) as startTime,
|
|
178
|
+
COUNT(*) as eventCount,
|
|
179
|
+
(SELECT message FROM workflow_events w2
|
|
180
|
+
WHERE w2.session_id = workflow_events.session_id
|
|
181
|
+
ORDER BY timestamp DESC LIMIT 1) as lastEvent
|
|
182
|
+
FROM workflow_events
|
|
183
|
+
GROUP BY session_id
|
|
184
|
+
ORDER BY startTime DESC
|
|
185
|
+
LIMIT ?
|
|
186
|
+
`);
|
|
187
|
+
|
|
188
|
+
return stmt.all(limit) as Array<{
|
|
189
|
+
sessionId: string;
|
|
190
|
+
startTime: number;
|
|
191
|
+
eventCount: number;
|
|
192
|
+
lastEvent: string;
|
|
193
|
+
}>;
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|