@retrace-dev/cli 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/LICENSE +202 -0
- package/README.md +13 -0
- package/dist/doctor.d.ts +16 -0
- package/dist/doctor.js +161 -0
- package/dist/export-cli.d.ts +2 -0
- package/dist/export-cli.js +107 -0
- package/dist/gdrive-cli.d.ts +2 -0
- package/dist/gdrive-cli.js +97 -0
- package/dist/git-hook.d.ts +61 -0
- package/dist/git-hook.js +365 -0
- package/dist/github-cli.d.ts +2 -0
- package/dist/github-cli.js +111 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +384 -0
- package/dist/is-main.d.ts +2 -0
- package/dist/is-main.js +13 -0
- package/dist/keys.d.ts +9 -0
- package/dist/keys.js +28 -0
- package/dist/remote-store.d.ts +211 -0
- package/dist/remote-store.js +75 -0
- package/dist/serve.d.ts +11 -0
- package/dist/serve.js +53 -0
- package/dist/sqlite-store.d.ts +246 -0
- package/dist/sqlite-store.js +121 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Retrace MCP server — lets any MCP-capable agent (Claude Code, Claude Desktop, Cursor…)
|
|
4
|
+
* log provenance events and retrace history.
|
|
5
|
+
*
|
|
6
|
+
* Config (env):
|
|
7
|
+
* RETRACE_DB path to local SQLite file (default ~/.retrace/retrace.db)
|
|
8
|
+
* RETRACE_URL if set, use the remote Worker instead of local SQLite
|
|
9
|
+
* RETRACE_TOKEN bearer token for the remote Worker
|
|
10
|
+
* RETRACE_PROJECT default project name; when set, WRITE tools (retrace_log/retrace_instruct) are pinned to it —
|
|
11
|
+
* a different explicit project is rejected. Set RETRACE_PROJECT_LOCK=0 to allow any project.
|
|
12
|
+
* RETRACE_COMMIT_LOCK action "committed" is reserved for the git hook; retrace_log rejects it. Set 0 to allow.
|
|
13
|
+
* RETRACE_ACTOR_LOCK actor identity is authoritative from env: retrace_log rejects human/system actors and ignores
|
|
14
|
+
* caller-supplied id/model/on_behalf_of; retrace_instruct only attributes to RETRACE_ON_BEHALF_OF.
|
|
15
|
+
* Set 0 to allow caller overrides (backfill / trusted contexts only).
|
|
16
|
+
* RETRACE_ACTOR default actor id for this agent (e.g. "claude-code")
|
|
17
|
+
* RETRACE_ACTOR_MODEL default model string
|
|
18
|
+
* RETRACE_ON_BEHALF_OF the human this agent works for (e.g. jordan@...)
|
|
19
|
+
* RETRACE_SESSION override location.session (default: CLAUDE_CODE_SESSION_ID or GROK_SESSION_ID, else a run id)
|
|
20
|
+
* RETRACE_DEVICE override location.device (default: os.hostname() — an opt-out, since a hostname is sealed into
|
|
21
|
+
* hash-covered bodies that share links serve pre-auth and no later redaction is possible)
|
|
22
|
+
* RETRACE_IDE / RETRACE_WORKSPACE override location.ide / location.workspace (default: detected from the IDE's own
|
|
23
|
+
* environment — Orca's ORCA_PANE_KEY / ORCA_WORKTREE_ID; nothing is guessed)
|
|
24
|
+
*/
|
|
25
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { mkdirSync } from "node:fs";
|
|
29
|
+
import { homedir, hostname } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { Actor, Action, ArtifactRef, Change, Location, Method, EventInput, applyDefaultRoles, appendEvent, verifyProject, explainEvent, renderTimeline, renderWhyChain, describeEvent, buildExportBundle, verifyExportBundle, renderReportHtml, parseSigningKey, newShareId, buildLineage, renderLineageDot, renderLineageMermaid, renderLineageText, buildProjectStatus, renderProjectStatus, } from "@retrace-dev/core";
|
|
33
|
+
import { writeFileSync } from "node:fs";
|
|
34
|
+
import { ensureSigningKey } from "./keys.js";
|
|
35
|
+
import { SqliteStore } from "./sqlite-store.js";
|
|
36
|
+
import { RemoteStore } from "./remote-store.js";
|
|
37
|
+
import { isMainModule } from "./is-main.js";
|
|
38
|
+
const env = process.env;
|
|
39
|
+
const DEFAULT_PROJECT = env.RETRACE_PROJECT ?? "default";
|
|
40
|
+
/** Read at buildServer() time (not module load) so tests and embedders can configure it via env before building. */
|
|
41
|
+
const readDefaultActor = () => ({
|
|
42
|
+
type: "agent",
|
|
43
|
+
id: env.RETRACE_ACTOR ?? "mcp-agent",
|
|
44
|
+
model: env.RETRACE_ACTOR_MODEL,
|
|
45
|
+
on_behalf_of: env.RETRACE_ON_BEHALF_OF,
|
|
46
|
+
});
|
|
47
|
+
/** Location keys only the server may set. These are evidence ABOUT the writer — which session and machine produced
|
|
48
|
+
* the event, which client and IDE it came from, whether a human was at a keyboard — so a caller that could assert
|
|
49
|
+
* them could forge the very thing they exist to prove. Same reasoning that produced RETRACE_ACTOR_LOCK (security
|
|
50
|
+
* review 2026-08-21). The caller keeps `path`/`url`/`environment`, which it genuinely knows better than the server. */
|
|
51
|
+
const SERVER_ONLY = ["session", "device", "client", "ide", "workspace", "surface"];
|
|
52
|
+
/** WHERE enrichment for the MCP write path (backlog #15): fill each location field from `defaults` ONLY where the
|
|
53
|
+
* caller supplied nothing — a caller value is never overwritten, except for SERVER_ONLY keys, which are dropped
|
|
54
|
+
* rather than merged. Exported for unit tests. */
|
|
55
|
+
export function enrichLocation(caller, defaults) {
|
|
56
|
+
const merged = { ...defaults };
|
|
57
|
+
for (const [k, v] of Object.entries(caller ?? {}))
|
|
58
|
+
if (v !== undefined && !SERVER_ONLY.includes(k))
|
|
59
|
+
merged[k] = v;
|
|
60
|
+
return merged;
|
|
61
|
+
}
|
|
62
|
+
/** MCP client name (from the `initialize` handshake) → the `system` slug Retrace uses for it. An unmapped name is
|
|
63
|
+
* slugged rather than dropped: it is still better evidence than the hardcoded "claude-code" every client used to get. */
|
|
64
|
+
const CLIENT_SYSTEM = new Map([
|
|
65
|
+
["claude-code", "claude-code"],
|
|
66
|
+
["claude-ai", "claude-desktop"],
|
|
67
|
+
["cursor-vscode", "cursor"],
|
|
68
|
+
["Visual Studio Code", "vscode"],
|
|
69
|
+
["grok-cli", "grok"],
|
|
70
|
+
["grok", "grok"],
|
|
71
|
+
// Measured 2026-08-29 against Grok Build TUI 1.0.13: initialize.name is "grok-shell-retrace".
|
|
72
|
+
["grok-shell-retrace", "grok"],
|
|
73
|
+
["gemini-cli", "gemini-cli"],
|
|
74
|
+
]);
|
|
75
|
+
export function clientSystem(name) {
|
|
76
|
+
// A Map, not an object literal: the client picks this name in the handshake, and an object would resolve
|
|
77
|
+
// "constructor"/"toString" through Object.prototype and return a function, which then fails Location's zod parse.
|
|
78
|
+
return CLIENT_SYSTEM.get(name) ?? (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown");
|
|
79
|
+
}
|
|
80
|
+
/** IDE / agent-development environment hosting this agent, read from the environment that IDE injects into the pane it
|
|
81
|
+
* launches. Orca (onorca.dev) sets ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_TERMINAL_HANDLE on every
|
|
82
|
+
* agent pane; ORCA_WORKTREE_ID is the one that earns its place, because Orca's premise is N agents in N isolated
|
|
83
|
+
* worktrees and without it their event streams are indistinguishable. Nothing is guessed — an IDE that does not name
|
|
84
|
+
* itself in the environment gets no `ide`, and `location.client` already identifies VS Code / Cursor / Claude Desktop
|
|
85
|
+
* from the MCP handshake. Orca's bin directory being on PATH is NOT taken as evidence: that only means it is installed.
|
|
86
|
+
* WSL caveat: Orca sets these Windows-side and forwards only HISTFILE and the git-credential vars through WSLENV, so
|
|
87
|
+
* they do not reach a WSL pane unless WSLENV names them (see README). */
|
|
88
|
+
export function detectIde(env) {
|
|
89
|
+
const orca = env.ORCA_PANE_KEY || env.ORCA_WORKTREE_ID || env.ORCA_TERMINAL_HANDLE;
|
|
90
|
+
return {
|
|
91
|
+
ide: env.RETRACE_IDE ?? (orca ? "orca" : undefined),
|
|
92
|
+
workspace: env.RETRACE_WORKSPACE ?? env.ORCA_WORKTREE_ID,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Harness session id when one is exposed. MCP and the live git hook must read the same keys so a commit joins the
|
|
96
|
+
* events that produced it. No fallback: absence is what makes the key discriminating (a human `git commit` has none). */
|
|
97
|
+
export function harnessSession(env = process.env) {
|
|
98
|
+
return env.RETRACE_SESSION ?? env.CLAUDE_CODE_SESSION_ID ?? env.GROK_SESSION_ID;
|
|
99
|
+
}
|
|
100
|
+
export function makeStore() {
|
|
101
|
+
if (env.RETRACE_URL)
|
|
102
|
+
return new RemoteStore(env.RETRACE_URL, env.RETRACE_TOKEN);
|
|
103
|
+
const path = env.RETRACE_DB ?? join(homedir(), ".retrace", "retrace.db");
|
|
104
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
105
|
+
return new SqliteStore(path);
|
|
106
|
+
}
|
|
107
|
+
export function buildServer(store = makeStore(), opts = {}) {
|
|
108
|
+
const server = new McpServer({ name: "retrace", version: "0.1.0" });
|
|
109
|
+
const remote = store instanceof RemoteStore ? store : null;
|
|
110
|
+
const pinned = opts.pinnedProject ?? env.RETRACE_PROJECT;
|
|
111
|
+
const lock = opts.lock ?? env.RETRACE_PROJECT_LOCK !== "0";
|
|
112
|
+
const commitLock = opts.commitLock ?? env.RETRACE_COMMIT_LOCK !== "0";
|
|
113
|
+
const actorLock = opts.actorLock ?? env.RETRACE_ACTOR_LOCK !== "0";
|
|
114
|
+
const defaultActor = readDefaultActor();
|
|
115
|
+
/** location.session: the harness's own session id when it exposes one, else a per-process run id (RETRACE_SESSION
|
|
116
|
+
* overrides both). Claude Code passes CLAUDE_CODE_SESSION_ID down to MCP subprocesses — verified 2026-08-27 against
|
|
117
|
+
* this server's own /proc/<pid>/environ under 2.1.250 — and Grok Build TUI exports GROK_SESSION_ID the same way.
|
|
118
|
+
* That shared env is what makes the id SHARED with the git hook: the same string lands on the agent's events and
|
|
119
|
+
* on the commits it drives, so `retrace_why` can walk between them. Two honest limits, both deliberate: a process
|
|
120
|
+
* environment is frozen at exec, so a session id re-minted mid-process is not seen until the server is respawned;
|
|
121
|
+
* and subagents inherit it, so this is a session key, not a per-run key. The random fallback stays for MCP clients
|
|
122
|
+
* that expose no session at all. */
|
|
123
|
+
const sessionId = harnessSession(env) ?? "run_" + randomUUID().replace(/-/g, "").slice(0, 12);
|
|
124
|
+
/** WHERE this server authoritatively knows (backlog #15). Evaluated per write rather than once, because the MCP
|
|
125
|
+
* client's identity only exists after the `initialize` handshake — which happens after buildServer() has returned.
|
|
126
|
+
* `url` is never stamped and no prod environment is synthesized — commit URLs and deploy environments belong to the
|
|
127
|
+
* git hook / Worker. `surface` is not stamped here either: an MCP subprocess is a harness child by construction, so
|
|
128
|
+
* the value would be the constant "agent" — hash bytes carrying no information. `system` follows the real client
|
|
129
|
+
* (a Cursor event used to be labelled "claude-code"); RETRACE_SYSTEM still overrides. */
|
|
130
|
+
const locationDefaults = () => {
|
|
131
|
+
const ci = server.server.getClientVersion();
|
|
132
|
+
return {
|
|
133
|
+
system: env.RETRACE_SYSTEM ?? (ci ? clientSystem(ci.name) : "claude-code"),
|
|
134
|
+
environment: env.RETRACE_ENVIRONMENT ?? env.RETRACE_ENV ?? "local",
|
|
135
|
+
path: process.cwd(),
|
|
136
|
+
device: env.RETRACE_DEVICE ?? hostname(),
|
|
137
|
+
session: sessionId,
|
|
138
|
+
client: ci ? `${ci.name}@${ci.version}` : undefined,
|
|
139
|
+
...detectIde(env),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
/** Resolve the project for a WRITE. If RETRACE_PROJECT is set (and lock on), any other explicit project is rejected
|
|
143
|
+
* so agents can't create stray projects by guessing a name. Read tools are not pinned. */
|
|
144
|
+
const writeProject = (requested) => {
|
|
145
|
+
const p = requested ?? pinned ?? DEFAULT_PROJECT;
|
|
146
|
+
if (lock && pinned && p !== pinned)
|
|
147
|
+
throw new Error(`project "${p}" is not allowed: this Retrace MCP server is pinned to project "${pinned}" (RETRACE_PROJECT). Omit project or pass "${pinned}". Set RETRACE_PROJECT_LOCK=0 to disable pinning.`);
|
|
148
|
+
return p;
|
|
149
|
+
};
|
|
150
|
+
/** "committed" events come only from the git post-commit hook — an agent logging one describes a commit it may not
|
|
151
|
+
* have made and misattributes it (this happened 2026-08-19). */
|
|
152
|
+
const guardAction = (action) => {
|
|
153
|
+
if (commitLock && action === "committed")
|
|
154
|
+
throw new Error(`action "committed" is reserved for the git hook, which already records every real commit with the correct actor. Log your work as "edited" or "decided" and reference the commit id in the artifact ids instead. Set RETRACE_COMMIT_LOCK=0 to override.`);
|
|
155
|
+
};
|
|
156
|
+
const ACTOR_LOCK_HINT = "Set RETRACE_ACTOR_LOCK=0 to override.";
|
|
157
|
+
/** Actor for a retrace_log WRITE (security review 2026-08-21, findings A1 + B4). With the lock on, this server only
|
|
158
|
+
* ever logs as its configured agent: human/system actors are refused outright, and id/model/on_behalf_of come from
|
|
159
|
+
* env — the caller may only decorate with display_name/version. Known limitation: cross-actor assertion (e.g. a
|
|
160
|
+
* "claude-cowork" event from a server configured as "claude-code") now needs the escape hatch; the credentialed
|
|
161
|
+
* per-actor version is backlog #6. */
|
|
162
|
+
const resolveActor = (callerActor) => {
|
|
163
|
+
if (!actorLock) {
|
|
164
|
+
return (callerActor?.type && callerActor.type !== "agent" ? callerActor : { ...defaultActor, ...(callerActor ?? {}) });
|
|
165
|
+
}
|
|
166
|
+
if (callerActor?.type === "human" || callerActor?.type === "system")
|
|
167
|
+
throw new Error(`actor.type "${callerActor.type}" is not allowed: this Retrace MCP server logs as its configured agent ("${defaultActor.id}"). Human instructions go through retrace_instruct; other human/system actors need the git hook or a credentialed context. ${ACTOR_LOCK_HINT}`);
|
|
168
|
+
return {
|
|
169
|
+
...defaultActor,
|
|
170
|
+
// A configured model stays authoritative. When it is deliberately unpinned, accept the runtime model reported
|
|
171
|
+
// by the agent client; the credential and actor lock still control id/type/on_behalf_of. This lets clients such
|
|
172
|
+
// as Gemini CLI switch models without sealing stale attribution into the ledger.
|
|
173
|
+
...(defaultActor.model === undefined && callerActor?.model !== undefined ? { model: callerActor.model } : {}),
|
|
174
|
+
...(callerActor?.display_name !== undefined ? { display_name: callerActor.display_name } : {}),
|
|
175
|
+
...(callerActor?.version !== undefined ? { version: callerActor.version } : {}),
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
/** Human actor for retrace_instruct. With the lock on, this server may only speak for its configured human. */
|
|
179
|
+
const resolveHuman = (humanId) => {
|
|
180
|
+
if (actorLock) {
|
|
181
|
+
const configured = env.RETRACE_ON_BEHALF_OF;
|
|
182
|
+
if (!configured)
|
|
183
|
+
throw new Error(`retrace_instruct cannot attribute an instruction to "${humanId}": RETRACE_ON_BEHALF_OF is not configured for this Retrace MCP server. Set it to the human this agent works for. ${ACTOR_LOCK_HINT}`);
|
|
184
|
+
if (humanId !== configured)
|
|
185
|
+
throw new Error(`human_id "${humanId}" is not allowed: this Retrace MCP server can only record instructions from its configured human ("${configured}", RETRACE_ON_BEHALF_OF), not an arbitrary one. ${ACTOR_LOCK_HINT}`);
|
|
186
|
+
}
|
|
187
|
+
return { type: "human", id: humanId };
|
|
188
|
+
};
|
|
189
|
+
server.registerTool("retrace_log", {
|
|
190
|
+
title: "Log a provenance event",
|
|
191
|
+
description: "Record WHO did WHAT to WHICH artifact(s), WHEN, WHERE, WHY and HOW. Call this after every meaningful action " +
|
|
192
|
+
"(create/edit/delete/execute/approve/send). Returns the event id — pass it as caused_by on follow-up actions " +
|
|
193
|
+
"so Retrace can reconstruct the causal chain back to the human instruction.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
project: z.string().optional().describe(`Project name (default: ${DEFAULT_PROJECT}). Omit it — the server pins writes to RETRACE_PROJECT and rejects other names.`),
|
|
196
|
+
action: Action.describe("Verb from the controlled vocabulary"),
|
|
197
|
+
action_detail: z.string().optional().describe("Required when action=other; free-text verb"),
|
|
198
|
+
artifacts: z.array(ArtifactRef).min(1).describe("Artifacts touched, e.g. {id:'repo:slcwitit/rpg#src/fight.ts', kind:'file', role:'both'}. role (PROV) = 'used' (input), " +
|
|
199
|
+
"'generated' (output) or 'both'. Omit it and the verb decides: read → used; created → generated; edited/moved/renamed → both; " +
|
|
200
|
+
"executed/sent/received/approved/rejected → used; deleted/other → unspecified. Always set role explicitly for OUTPUTS of an " +
|
|
201
|
+
"executed/sent action (a deployment, a report, a message) — the default treats those refs as inputs."),
|
|
202
|
+
intent: z.string().optional().describe("WHY: the reason for this action, in one sentence"),
|
|
203
|
+
caused_by: z.string().optional().describe("Event id of the instruction/action that caused this one"),
|
|
204
|
+
actor: Actor.partial().optional().describe("Override the default actor (defaults from env)"),
|
|
205
|
+
change: Change.optional().describe("WHAT changed: summary, diff, before/after hashes"),
|
|
206
|
+
location: Location.optional().describe("WHERE: path/url/environment/system. session/device/client/ide/workspace/surface are stamped by the server and ignored if you send them."),
|
|
207
|
+
method: Method.optional().describe("HOW: tool, instruction ref, params, tokens/cost"),
|
|
208
|
+
timestamp: z.string().optional().describe("ISO 8601; defaults to now"),
|
|
209
|
+
idempotency_key: z.string().optional(),
|
|
210
|
+
tags: z.array(z.string()).optional(),
|
|
211
|
+
},
|
|
212
|
+
}, async (args) => {
|
|
213
|
+
guardAction(args.action);
|
|
214
|
+
const actor = resolveActor(args.actor); // actor lock first — a rejected write must not get this far
|
|
215
|
+
// WHERE enrichment (backlog #15) and PROV role fill-absent: after the actor lock, before sealing (local appendEvent
|
|
216
|
+
// or the Worker's POST /events). A caller-supplied role is never overwritten; refs whose verb has no default stay absent.
|
|
217
|
+
const input = EventInput.parse({
|
|
218
|
+
...args,
|
|
219
|
+
project: writeProject(args.project),
|
|
220
|
+
actor,
|
|
221
|
+
artifacts: applyDefaultRoles(args.action, args.artifacts),
|
|
222
|
+
location: enrichLocation(args.location, locationDefaults()),
|
|
223
|
+
});
|
|
224
|
+
const { event, deduped } = remote ? await remote.append(input) : await appendEvent(store, input);
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text", text: `${deduped ? "(deduped) " : ""}logged ${event.id} seq=${event.seq}\n${describeEvent(event)}` }],
|
|
227
|
+
structuredContent: { id: event.id, seq: event.seq, hash: event.hash, deduped },
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
server.registerTool("retrace_instruct", {
|
|
231
|
+
title: "Log a human instruction",
|
|
232
|
+
description: "Shortcut to record that a human gave an instruction (the root of a causal chain). Use at the start of a task " +
|
|
233
|
+
"with the user's request. Returns an event id to use as caused_by for the work that follows.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
project: z.string().optional(),
|
|
236
|
+
human_id: z.string().describe("Who gave the instruction (email or name)"),
|
|
237
|
+
instruction: z.string().describe("The instruction text (or a faithful summary)"),
|
|
238
|
+
artifacts: z.array(ArtifactRef).optional().describe("What the instruction is about; defaults to a task artifact (role generated). Supplied refs keep whatever role you give them — an instruction is about a file, it does not generate it."),
|
|
239
|
+
timestamp: z.string().optional().describe("ISO 8601; defaults to now"),
|
|
240
|
+
},
|
|
241
|
+
}, async (args) => {
|
|
242
|
+
const actor = resolveHuman(args.human_id); // actor lock first
|
|
243
|
+
const input = EventInput.parse({
|
|
244
|
+
project: writeProject(args.project),
|
|
245
|
+
actor,
|
|
246
|
+
action: "instructed",
|
|
247
|
+
// PROV role: the instruction brings its task into being (generated). Caller-supplied refs are stored as given —
|
|
248
|
+
// the instruction is ABOUT them, so no default is applied (absent = unspecified).
|
|
249
|
+
artifacts: args.artifacts ?? [{ id: `task:${args.instruction.slice(0, 60)}`, kind: "task", label: args.instruction.slice(0, 60), role: "generated" }],
|
|
250
|
+
intent: args.instruction,
|
|
251
|
+
timestamp: args.timestamp,
|
|
252
|
+
method: { tool: "chat", automated: false },
|
|
253
|
+
// WHERE enrichment (backlog #15): env-only — retrace_instruct deliberately has no caller-facing location param.
|
|
254
|
+
location: enrichLocation(undefined, locationDefaults()),
|
|
255
|
+
});
|
|
256
|
+
const { event } = remote ? await remote.append(input) : await appendEvent(store, input);
|
|
257
|
+
return { content: [{ type: "text", text: `instruction logged ${event.id}` }], structuredContent: { id: event.id } };
|
|
258
|
+
});
|
|
259
|
+
server.registerTool("retrace_history", {
|
|
260
|
+
title: "Retrace history",
|
|
261
|
+
description: "Timeline of events for a project, optionally filtered by artifact, actor, action, time range or text.",
|
|
262
|
+
inputSchema: {
|
|
263
|
+
project: z.string().optional(),
|
|
264
|
+
artifact_id: z.string().optional(),
|
|
265
|
+
actor_id: z.string().optional(),
|
|
266
|
+
actor_type: z.enum(["human", "agent", "system"]).optional(),
|
|
267
|
+
action: Action.optional(),
|
|
268
|
+
since: z.string().optional(),
|
|
269
|
+
until: z.string().optional(),
|
|
270
|
+
text: z.string().optional().describe("substring match across the event"),
|
|
271
|
+
limit: z.number().int().positive().max(1000).optional(),
|
|
272
|
+
},
|
|
273
|
+
}, async (args) => {
|
|
274
|
+
const events = await store.history({ ...args, project: args.project ?? DEFAULT_PROJECT });
|
|
275
|
+
return { content: [{ type: "text", text: renderTimeline(events) }], structuredContent: { count: events.length, events } };
|
|
276
|
+
});
|
|
277
|
+
server.registerTool("retrace_why", {
|
|
278
|
+
title: "Explain why an event happened",
|
|
279
|
+
description: "Follow caused_by links from an event back to the originating human instruction.",
|
|
280
|
+
inputSchema: { event_id: z.string() },
|
|
281
|
+
}, async ({ event_id }) => {
|
|
282
|
+
const chain = await explainEvent(store, event_id);
|
|
283
|
+
if (!chain.length)
|
|
284
|
+
return { content: [{ type: "text", text: `no event ${event_id}` }], isError: true };
|
|
285
|
+
return { content: [{ type: "text", text: renderWhyChain(chain) }], structuredContent: { chain } };
|
|
286
|
+
});
|
|
287
|
+
server.registerTool("retrace_status", {
|
|
288
|
+
title: "Project transparency status",
|
|
289
|
+
description: "One canonical view of chain integrity, causal coverage, capture gaps, actors, and integration freshness for humans and agents.",
|
|
290
|
+
inputSchema: { project: z.string().optional() },
|
|
291
|
+
}, async ({ project }) => {
|
|
292
|
+
const p = project ?? DEFAULT_PROJECT;
|
|
293
|
+
const status = remote ? await remote.status(p) : await buildProjectStatus(store, p);
|
|
294
|
+
return { content: [{ type: "text", text: renderProjectStatus(status) }], structuredContent: { status } };
|
|
295
|
+
});
|
|
296
|
+
server.registerTool("retrace_verify", {
|
|
297
|
+
title: "Verify chain integrity",
|
|
298
|
+
description: "Recompute the hash chain for a project and report whether history is intact.",
|
|
299
|
+
inputSchema: { project: z.string().optional() },
|
|
300
|
+
}, async ({ project }) => {
|
|
301
|
+
const p = project ?? DEFAULT_PROJECT;
|
|
302
|
+
const r = remote ? await remote.verify(p) : await verifyProject(store, p);
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: r.ok ? `OK — ${r.checked} events verified for '${p}'` : `BROKEN at seq ${r.first_bad_seq}: ${r.reason}` }],
|
|
305
|
+
structuredContent: { ...r },
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
server.registerTool("retrace_export", {
|
|
309
|
+
title: "Signed provenance export",
|
|
310
|
+
description: "Build a signed (Ed25519) provenance bundle for a project or one artifact — the 'Prove' step. Optionally writes the JSON " +
|
|
311
|
+
"and a printable HTML report to disk. Anyone can verify the bundle offline with `retrace-export verify`.",
|
|
312
|
+
inputSchema: {
|
|
313
|
+
project: z.string().optional(),
|
|
314
|
+
artifact_id: z.string().optional().describe("Limit to one artifact (e.g. repo:rpg#src/fight.ts)"),
|
|
315
|
+
out_json: z.string().optional().describe("Path to write the signed JSON bundle"),
|
|
316
|
+
out_html: z.string().optional().describe("Path to write the printable HTML report (open → Print → Save as PDF)"),
|
|
317
|
+
},
|
|
318
|
+
}, async (args) => {
|
|
319
|
+
const project = args.project ?? DEFAULT_PROJECT;
|
|
320
|
+
const bundle = remote
|
|
321
|
+
? await remote.export({ project, artifact_id: args.artifact_id })
|
|
322
|
+
: await buildExportBundle(store, { project, artifact_id: args.artifact_id }, { signingKey: parseSigningKey(env.RETRACE_SIGNING_KEY) ?? (await ensureSigningKey()).privateKey, issuerName: env.RETRACE_ISSUER });
|
|
323
|
+
const verdict = await verifyExportBundle(bundle);
|
|
324
|
+
if (args.out_json)
|
|
325
|
+
writeFileSync(args.out_json, JSON.stringify(bundle, null, 2));
|
|
326
|
+
if (args.out_html)
|
|
327
|
+
writeFileSync(args.out_html, renderReportHtml(bundle, verdict));
|
|
328
|
+
const summary = `${bundle.events.length} events · chain ${bundle.chain.ok ? "intact" : "BROKEN"} · signature ${verdict.signature}${bundle.issuer ? " (kid " + bundle.issuer.kid + ")" : ""}` +
|
|
329
|
+
(args.out_json ? `\njson → ${args.out_json}` : "") + (args.out_html ? `\nreport → ${args.out_html}` : "");
|
|
330
|
+
return { content: [{ type: "text", text: summary }], structuredContent: { events: bundle.events.length, chain_ok: bundle.chain.ok, signature: verdict.signature, kid: bundle.issuer?.kid, ...(args.out_json || args.out_html ? {} : { bundle }) } };
|
|
331
|
+
});
|
|
332
|
+
server.registerTool("retrace_share", {
|
|
333
|
+
title: "Create read-only share link",
|
|
334
|
+
description: "Create a public, read-only share link (timeline + verify + signed export + printable report) scoped to a project or one artifact.",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
project: z.string().optional(),
|
|
337
|
+
artifact_id: z.string().optional(),
|
|
338
|
+
label: z.string().optional().describe("Shown as the report title, e.g. 'Jab counter — client review'"),
|
|
339
|
+
expires_in_days: z.number().int().positive().optional(),
|
|
340
|
+
},
|
|
341
|
+
}, async (args) => {
|
|
342
|
+
const project = args.project ?? DEFAULT_PROJECT;
|
|
343
|
+
if (remote) {
|
|
344
|
+
const r = await remote.share({ project, artifact_id: args.artifact_id, label: args.label, expires_in_days: args.expires_in_days });
|
|
345
|
+
return { content: [{ type: "text", text: `${r.url}\nreport: ${r.url}/report` }], structuredContent: { ...r } };
|
|
346
|
+
}
|
|
347
|
+
const id = newShareId();
|
|
348
|
+
const now = new Date();
|
|
349
|
+
const share = { id, project, artifact_id: args.artifact_id, label: args.label, created_at: now.toISOString(), expires_at: args.expires_in_days ? new Date(now.getTime() + args.expires_in_days * 86400000).toISOString() : undefined };
|
|
350
|
+
await store.createShare(share);
|
|
351
|
+
const base = env.RETRACE_PUBLIC_URL ?? `http://localhost:${env.RETRACE_PORT ?? 7777}`;
|
|
352
|
+
const url = `${base}/s/${id}`;
|
|
353
|
+
return { content: [{ type: "text", text: `${url}\nreport: ${url}/report\n(local links resolve while \`retrace-serve\` is running)` }], structuredContent: { share, url, report_url: `${url}/report` } };
|
|
354
|
+
});
|
|
355
|
+
server.registerTool("retrace_lineage", {
|
|
356
|
+
title: "Artifact lineage graph",
|
|
357
|
+
description: "Which artifacts came from which: explicit derived_from links plus causal flow (instruction → files touched → PR …). " +
|
|
358
|
+
"Returns text by default; format=dot (Graphviz) or mermaid for diagrams; format=json for nodes/edges.",
|
|
359
|
+
inputSchema: {
|
|
360
|
+
project: z.string().optional(),
|
|
361
|
+
artifact_id: z.string().optional().describe("Focus on one artifact (its events + causal ancestors)"),
|
|
362
|
+
format: z.enum(["text", "dot", "mermaid", "json"]).optional(),
|
|
363
|
+
include_actors: z.boolean().optional().describe("Add human/agent nodes with 'touched' edges"),
|
|
364
|
+
},
|
|
365
|
+
}, async (args) => {
|
|
366
|
+
const project = args.project ?? DEFAULT_PROJECT;
|
|
367
|
+
const events = args.artifact_id
|
|
368
|
+
? (remote ? await remote.export({ project, artifact_id: args.artifact_id }) : await buildExportBundle(store, { project, artifact_id: args.artifact_id })).events
|
|
369
|
+
: await store.all(project);
|
|
370
|
+
const l = buildLineage(events, { includeActors: !!args.include_actors });
|
|
371
|
+
const fmt = args.format ?? "text";
|
|
372
|
+
const text = fmt === "dot" ? renderLineageDot(l) : fmt === "mermaid" ? renderLineageMermaid(l) : fmt === "json" ? JSON.stringify(l, null, 2) : renderLineageText(l);
|
|
373
|
+
return { content: [{ type: "text", text }], structuredContent: { nodes: l.nodes.length, edges: l.edges.length, ...(fmt === "json" ? { lineage: l } : {}) } };
|
|
374
|
+
});
|
|
375
|
+
server.registerTool("retrace_projects", { title: "List projects", description: "List projects that have events.", inputSchema: {} }, async () => {
|
|
376
|
+
const ps = await store.projects();
|
|
377
|
+
return { content: [{ type: "text", text: ps.join("\n") || "(none)" }], structuredContent: { projects: ps } };
|
|
378
|
+
});
|
|
379
|
+
return server;
|
|
380
|
+
}
|
|
381
|
+
if (isMainModule(import.meta.url)) {
|
|
382
|
+
const server = buildServer();
|
|
383
|
+
await server.connect(new StdioServerTransport());
|
|
384
|
+
}
|
package/dist/is-main.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/** True when an ES module is the process entry point, including npm bin symlinks. */
|
|
4
|
+
export function isMainModule(moduleUrl, entryPoint = process.argv[1]) {
|
|
5
|
+
if (!entryPoint)
|
|
6
|
+
return false;
|
|
7
|
+
try {
|
|
8
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPoint);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function keyPath(): string;
|
|
2
|
+
export declare function loadSigningKey(): JsonWebKey | null;
|
|
3
|
+
export declare function ensureSigningKey(): Promise<{
|
|
4
|
+
privateKey: JsonWebKey;
|
|
5
|
+
publicKey: JsonWebKey;
|
|
6
|
+
kid: string;
|
|
7
|
+
path: string;
|
|
8
|
+
created: boolean;
|
|
9
|
+
}>;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Local signing key management: ~/.retrace/signing-key.json (private JWK), auto-created on first use. */
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { generateSigningKey, publicFromPrivate, keyId } from "@retrace-dev/core";
|
|
6
|
+
export function keyPath() {
|
|
7
|
+
return process.env.RETRACE_SIGNING_KEY_FILE ?? join(homedir(), ".retrace", "signing-key.json");
|
|
8
|
+
}
|
|
9
|
+
export function loadSigningKey() {
|
|
10
|
+
const p = keyPath();
|
|
11
|
+
if (!existsSync(p))
|
|
12
|
+
return null;
|
|
13
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
export async function ensureSigningKey() {
|
|
16
|
+
const p = keyPath();
|
|
17
|
+
const existing = loadSigningKey();
|
|
18
|
+
if (existing)
|
|
19
|
+
return { privateKey: existing, publicKey: publicFromPrivate(existing), kid: await keyId(publicFromPrivate(existing)), path: p, created: false };
|
|
20
|
+
const kp = await generateSigningKey();
|
|
21
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
22
|
+
writeFileSync(p, JSON.stringify(kp.privateKey, null, 2) + "\n");
|
|
23
|
+
try {
|
|
24
|
+
chmodSync(p, 0o600);
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
return { ...kp, path: p, created: true };
|
|
28
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** Remote store: talks to the Retrace Cloudflare Worker over HTTP. Set RETRACE_URL (+ RETRACE_TOKEN). */
|
|
2
|
+
import { Event, EventStore, HistoryQuery, VerifyResult, EventInput, Share, ExportBundle, ProjectStatus } from "@retrace-dev/core";
|
|
3
|
+
/** Consistent headers for CLI-originated requests, including runtimes that require an explicit user agent. */
|
|
4
|
+
export declare function retraceHeaders(token?: string): Record<string, string>;
|
|
5
|
+
export declare class RemoteStore implements EventStore {
|
|
6
|
+
private baseUrl;
|
|
7
|
+
private token?;
|
|
8
|
+
constructor(baseUrl: string, token?: string | undefined);
|
|
9
|
+
private req;
|
|
10
|
+
/** Remote appends server-side (chain sealing must happen where the head lives). */
|
|
11
|
+
append(input: EventInput): Promise<{
|
|
12
|
+
event: Event;
|
|
13
|
+
deduped: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
verify(project: string): Promise<VerifyResult>;
|
|
16
|
+
status(project: string): Promise<ProjectStatus>;
|
|
17
|
+
createShare(): Promise<void>;
|
|
18
|
+
getShare(id: string): Promise<Share | null>;
|
|
19
|
+
/** Server-side share creation; returns share + url. */
|
|
20
|
+
share(body: {
|
|
21
|
+
project: string;
|
|
22
|
+
artifact_id?: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
expires_in_days?: number;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
share: Share;
|
|
27
|
+
url: string;
|
|
28
|
+
}>;
|
|
29
|
+
export(scope: {
|
|
30
|
+
project: string;
|
|
31
|
+
artifact_id?: string;
|
|
32
|
+
}): Promise<ExportBundle>;
|
|
33
|
+
head(project: string): Promise<{
|
|
34
|
+
seq: number;
|
|
35
|
+
hash: string;
|
|
36
|
+
} | null>;
|
|
37
|
+
insert(): Promise<void>;
|
|
38
|
+
byIdempotencyKey(): Promise<Event | null>;
|
|
39
|
+
get(id: string): Promise<{
|
|
40
|
+
id: string;
|
|
41
|
+
project: string;
|
|
42
|
+
actor: {
|
|
43
|
+
type: "human" | "agent" | "system";
|
|
44
|
+
id: string;
|
|
45
|
+
display_name?: string | undefined;
|
|
46
|
+
model?: string | undefined;
|
|
47
|
+
version?: string | undefined;
|
|
48
|
+
on_behalf_of?: string | undefined;
|
|
49
|
+
};
|
|
50
|
+
action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
|
|
51
|
+
artifacts: {
|
|
52
|
+
id: string;
|
|
53
|
+
kind?: string | undefined;
|
|
54
|
+
label?: string | undefined;
|
|
55
|
+
derived_from?: string[] | undefined;
|
|
56
|
+
role?: "used" | "generated" | "both" | undefined;
|
|
57
|
+
}[];
|
|
58
|
+
timestamp: string;
|
|
59
|
+
seq: number;
|
|
60
|
+
prev_hash: string;
|
|
61
|
+
hash: string;
|
|
62
|
+
received_at: string;
|
|
63
|
+
action_detail?: string | undefined;
|
|
64
|
+
change?: {
|
|
65
|
+
before_hash?: string | undefined;
|
|
66
|
+
after_hash?: string | undefined;
|
|
67
|
+
diff?: string | undefined;
|
|
68
|
+
summary?: string | undefined;
|
|
69
|
+
} | undefined;
|
|
70
|
+
duration_ms?: number | undefined;
|
|
71
|
+
location?: {
|
|
72
|
+
system?: string | undefined;
|
|
73
|
+
path?: string | undefined;
|
|
74
|
+
url?: string | undefined;
|
|
75
|
+
environment?: string | undefined;
|
|
76
|
+
device?: string | undefined;
|
|
77
|
+
session?: string | undefined;
|
|
78
|
+
client?: string | undefined;
|
|
79
|
+
ide?: string | undefined;
|
|
80
|
+
workspace?: string | undefined;
|
|
81
|
+
surface?: "agent" | "tty" | undefined;
|
|
82
|
+
} | undefined;
|
|
83
|
+
intent?: string | undefined;
|
|
84
|
+
caused_by?: string | undefined;
|
|
85
|
+
method?: {
|
|
86
|
+
params?: Record<string, unknown> | undefined;
|
|
87
|
+
tool?: string | undefined;
|
|
88
|
+
instruction?: string | undefined;
|
|
89
|
+
automated?: boolean | undefined;
|
|
90
|
+
tokens?: number | undefined;
|
|
91
|
+
cost_usd?: number | undefined;
|
|
92
|
+
} | undefined;
|
|
93
|
+
idempotency_key?: string | undefined;
|
|
94
|
+
tags?: string[] | undefined;
|
|
95
|
+
} | null>;
|
|
96
|
+
all(project: string): Promise<{
|
|
97
|
+
id: string;
|
|
98
|
+
project: string;
|
|
99
|
+
actor: {
|
|
100
|
+
type: "human" | "agent" | "system";
|
|
101
|
+
id: string;
|
|
102
|
+
display_name?: string | undefined;
|
|
103
|
+
model?: string | undefined;
|
|
104
|
+
version?: string | undefined;
|
|
105
|
+
on_behalf_of?: string | undefined;
|
|
106
|
+
};
|
|
107
|
+
action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
|
|
108
|
+
artifacts: {
|
|
109
|
+
id: string;
|
|
110
|
+
kind?: string | undefined;
|
|
111
|
+
label?: string | undefined;
|
|
112
|
+
derived_from?: string[] | undefined;
|
|
113
|
+
role?: "used" | "generated" | "both" | undefined;
|
|
114
|
+
}[];
|
|
115
|
+
timestamp: string;
|
|
116
|
+
seq: number;
|
|
117
|
+
prev_hash: string;
|
|
118
|
+
hash: string;
|
|
119
|
+
received_at: string;
|
|
120
|
+
action_detail?: string | undefined;
|
|
121
|
+
change?: {
|
|
122
|
+
before_hash?: string | undefined;
|
|
123
|
+
after_hash?: string | undefined;
|
|
124
|
+
diff?: string | undefined;
|
|
125
|
+
summary?: string | undefined;
|
|
126
|
+
} | undefined;
|
|
127
|
+
duration_ms?: number | undefined;
|
|
128
|
+
location?: {
|
|
129
|
+
system?: string | undefined;
|
|
130
|
+
path?: string | undefined;
|
|
131
|
+
url?: string | undefined;
|
|
132
|
+
environment?: string | undefined;
|
|
133
|
+
device?: string | undefined;
|
|
134
|
+
session?: string | undefined;
|
|
135
|
+
client?: string | undefined;
|
|
136
|
+
ide?: string | undefined;
|
|
137
|
+
workspace?: string | undefined;
|
|
138
|
+
surface?: "agent" | "tty" | undefined;
|
|
139
|
+
} | undefined;
|
|
140
|
+
intent?: string | undefined;
|
|
141
|
+
caused_by?: string | undefined;
|
|
142
|
+
method?: {
|
|
143
|
+
params?: Record<string, unknown> | undefined;
|
|
144
|
+
tool?: string | undefined;
|
|
145
|
+
instruction?: string | undefined;
|
|
146
|
+
automated?: boolean | undefined;
|
|
147
|
+
tokens?: number | undefined;
|
|
148
|
+
cost_usd?: number | undefined;
|
|
149
|
+
} | undefined;
|
|
150
|
+
idempotency_key?: string | undefined;
|
|
151
|
+
tags?: string[] | undefined;
|
|
152
|
+
}[]>;
|
|
153
|
+
projects(): Promise<string[]>;
|
|
154
|
+
history(q: HistoryQuery): Promise<{
|
|
155
|
+
id: string;
|
|
156
|
+
project: string;
|
|
157
|
+
actor: {
|
|
158
|
+
type: "human" | "agent" | "system";
|
|
159
|
+
id: string;
|
|
160
|
+
display_name?: string | undefined;
|
|
161
|
+
model?: string | undefined;
|
|
162
|
+
version?: string | undefined;
|
|
163
|
+
on_behalf_of?: string | undefined;
|
|
164
|
+
};
|
|
165
|
+
action: "received" | "created" | "edited" | "deleted" | "read" | "executed" | "approved" | "rejected" | "sent" | "moved" | "renamed" | "instructed" | "committed" | "merged" | "other";
|
|
166
|
+
artifacts: {
|
|
167
|
+
id: string;
|
|
168
|
+
kind?: string | undefined;
|
|
169
|
+
label?: string | undefined;
|
|
170
|
+
derived_from?: string[] | undefined;
|
|
171
|
+
role?: "used" | "generated" | "both" | undefined;
|
|
172
|
+
}[];
|
|
173
|
+
timestamp: string;
|
|
174
|
+
seq: number;
|
|
175
|
+
prev_hash: string;
|
|
176
|
+
hash: string;
|
|
177
|
+
received_at: string;
|
|
178
|
+
action_detail?: string | undefined;
|
|
179
|
+
change?: {
|
|
180
|
+
before_hash?: string | undefined;
|
|
181
|
+
after_hash?: string | undefined;
|
|
182
|
+
diff?: string | undefined;
|
|
183
|
+
summary?: string | undefined;
|
|
184
|
+
} | undefined;
|
|
185
|
+
duration_ms?: number | undefined;
|
|
186
|
+
location?: {
|
|
187
|
+
system?: string | undefined;
|
|
188
|
+
path?: string | undefined;
|
|
189
|
+
url?: string | undefined;
|
|
190
|
+
environment?: string | undefined;
|
|
191
|
+
device?: string | undefined;
|
|
192
|
+
session?: string | undefined;
|
|
193
|
+
client?: string | undefined;
|
|
194
|
+
ide?: string | undefined;
|
|
195
|
+
workspace?: string | undefined;
|
|
196
|
+
surface?: "agent" | "tty" | undefined;
|
|
197
|
+
} | undefined;
|
|
198
|
+
intent?: string | undefined;
|
|
199
|
+
caused_by?: string | undefined;
|
|
200
|
+
method?: {
|
|
201
|
+
params?: Record<string, unknown> | undefined;
|
|
202
|
+
tool?: string | undefined;
|
|
203
|
+
instruction?: string | undefined;
|
|
204
|
+
automated?: boolean | undefined;
|
|
205
|
+
tokens?: number | undefined;
|
|
206
|
+
cost_usd?: number | undefined;
|
|
207
|
+
} | undefined;
|
|
208
|
+
idempotency_key?: string | undefined;
|
|
209
|
+
tags?: string[] | undefined;
|
|
210
|
+
}[]>;
|
|
211
|
+
}
|