@retrace-dev/core 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 +5 -0
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +70 -0
- package/dist/explain.d.ts +8 -0
- package/dist/explain.js +30 -0
- package/dist/export.d.ts +53 -0
- package/dist/export.js +85 -0
- package/dist/gdrive.d.ts +32 -0
- package/dist/gdrive.js +129 -0
- package/dist/github.d.ts +30 -0
- package/dist/github.js +112 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lineage.d.ts +50 -0
- package/dist/lineage.js +150 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +79 -0
- package/dist/router.d.ts +136 -0
- package/dist/router.js +391 -0
- package/dist/schema.d.ts +708 -0
- package/dist/schema.js +177 -0
- package/dist/signing.d.ts +13 -0
- package/dist/signing.js +55 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.js +85 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +84 -0
- package/dist/ui-html.d.ts +1 -0
- package/dist/ui-html.js +2 -0
- package/package.json +26 -0
- package/ui/retrace.html +479 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrace core schema — the six dimensions of provenance:
|
|
3
|
+
* WHO (actor) · WHAT (action + artifacts + change) · WHEN (timestamp/seq)
|
|
4
|
+
* WHERE (location) · WHY (intent + caused_by) · HOW (method)
|
|
5
|
+
* plus integrity (hash chain).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately close to W3C PROV (Agent / Activity / Entity) so we can export later.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
export const ActorType = z.enum(["human", "agent", "system"]);
|
|
11
|
+
export const Actor = z.object({
|
|
12
|
+
type: ActorType,
|
|
13
|
+
/** Stable identifier: email, agent name, service id */
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
display_name: z.string().optional(),
|
|
16
|
+
/** For agents: model + version that performed the action */
|
|
17
|
+
model: z.string().optional(),
|
|
18
|
+
version: z.string().optional(),
|
|
19
|
+
/** Delegation: an agent acting for a human, or a sub-agent for a parent agent */
|
|
20
|
+
on_behalf_of: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
/** Small controlled verb vocabulary. `other` requires `action_detail`. */
|
|
23
|
+
export const Action = z.enum([
|
|
24
|
+
"created",
|
|
25
|
+
"edited",
|
|
26
|
+
"deleted",
|
|
27
|
+
"read",
|
|
28
|
+
"executed",
|
|
29
|
+
"approved",
|
|
30
|
+
"rejected",
|
|
31
|
+
"sent",
|
|
32
|
+
"received",
|
|
33
|
+
"moved",
|
|
34
|
+
"renamed",
|
|
35
|
+
"instructed",
|
|
36
|
+
"committed",
|
|
37
|
+
"merged",
|
|
38
|
+
"other",
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* PROV role of an artifact within an event: was it an input the activity `used`, an output it `generated`, or `both`
|
|
42
|
+
* (read then rewritten). Optional — absence means "unspecified" and is a legal, permanent state: events sealed before
|
|
43
|
+
* this field existed are never backfilled or re-hashed (absence is information).
|
|
44
|
+
* Export mapping (for a future prov exporter): used → prov:used (Activity→Entity), generated → prov:wasGeneratedBy
|
|
45
|
+
* (Entity→Activity), both → both edges, absent → degrades to prov:wasInfluencedBy.
|
|
46
|
+
* Distinct from `derived_from`, which is Entity→Entity (prov:wasDerivedFrom) and unchanged. Invalidation (a deleted
|
|
47
|
+
* artifact, prov:wasInvalidatedBy) is deliberately NOT a role — a deleted ref stays absent until that is a first-class edge.
|
|
48
|
+
*/
|
|
49
|
+
export const ArtifactRole = z.enum(["used", "generated", "both"]);
|
|
50
|
+
export const ArtifactRef = z.object({
|
|
51
|
+
/** Stable id for the thing being worked on, e.g. "repo:slcwitit/rpg#src/fight.ts" or "doc:abc123" */
|
|
52
|
+
id: z.string().min(1),
|
|
53
|
+
kind: z.string().optional(), // file, doc, dataset, pr, message, decision, ...
|
|
54
|
+
label: z.string().optional(),
|
|
55
|
+
/** Lineage: this artifact was derived from these */
|
|
56
|
+
derived_from: z.array(z.string()).optional(),
|
|
57
|
+
/** PROV: input (used) / output (generated) / both. Body-only, hash-covered on new events; see ArtifactRole. */
|
|
58
|
+
role: ArtifactRole.optional(),
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Default role of an artifact ref for an action verb, for when the caller says nothing. `undefined` = leave absent.
|
|
62
|
+
* read → used · created/committed/merged → generated · edited/moved/renamed → both (the prior state is read, the new
|
|
63
|
+
* one written) · executed/sent/received/approved/rejected → used (the thing run/sent/reviewed was an input; an OUTPUT
|
|
64
|
+
* such as a deployment or report must be said by the caller) · deleted/instructed/other → absent.
|
|
65
|
+
* Adapters stamp what they authoritatively know and only fall back to this where the verb alone is the truth.
|
|
66
|
+
*/
|
|
67
|
+
export function defaultArtifactRole(action) {
|
|
68
|
+
switch (action) {
|
|
69
|
+
case "read": return "used";
|
|
70
|
+
case "created":
|
|
71
|
+
case "committed":
|
|
72
|
+
case "merged": return "generated";
|
|
73
|
+
case "edited":
|
|
74
|
+
case "moved":
|
|
75
|
+
case "renamed": return "both";
|
|
76
|
+
case "executed":
|
|
77
|
+
case "sent":
|
|
78
|
+
case "received":
|
|
79
|
+
case "approved":
|
|
80
|
+
case "rejected": return "used";
|
|
81
|
+
default: return undefined; // deleted, instructed, other
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Fill `role` from defaultArtifactRole ONLY where a ref has none — a caller-supplied role is never overwritten. Refs
|
|
85
|
+
* that get no default come back as they were (no `role` key, so hashes of role-less inputs are unaffected). */
|
|
86
|
+
export function applyDefaultRoles(action, artifacts) {
|
|
87
|
+
const def = defaultArtifactRole(action);
|
|
88
|
+
return artifacts.map((a) => (a.role !== undefined || def === undefined ? a : { ...a, role: def }));
|
|
89
|
+
}
|
|
90
|
+
export const Change = z.object({
|
|
91
|
+
before_hash: z.string().optional(),
|
|
92
|
+
after_hash: z.string().optional(),
|
|
93
|
+
diff: z.string().optional(),
|
|
94
|
+
summary: z.string().optional(),
|
|
95
|
+
});
|
|
96
|
+
export const Location = z.object({
|
|
97
|
+
/** repo path, doc section, URL, table, etc. */
|
|
98
|
+
path: z.string().optional(),
|
|
99
|
+
url: z.string().optional(),
|
|
100
|
+
environment: z.string().optional(), // prod, staging, local, ...
|
|
101
|
+
device: z.string().optional(),
|
|
102
|
+
system: z.string().optional(), // github, gdocs, cursor, claude-code, ...
|
|
103
|
+
/** Run/session id of the producing process (backlog #15; body-only, like every location field). On the MCP path
|
|
104
|
+
* this is the harness's own session id when it exposes one (CLAUDE_CODE_SESSION_ID or GROK_SESSION_ID), so the same string appears on
|
|
105
|
+
* events from the agent AND on the commits it drives. It is a *session* key — subagents share it — not a per-run id. */
|
|
106
|
+
session: z.string().optional(),
|
|
107
|
+
/** The MCP client that drove the write, verbatim from the `initialize` handshake as "<name>@<version>" — e.g.
|
|
108
|
+
* "claude-code@2.1.250", "cursor-vscode@1.7.3". Server-stamped only: it is evidence ABOUT the writer, so the
|
|
109
|
+
* writer may not assert it (see SERVER_ONLY in the MCP server). */
|
|
110
|
+
client: z.string().optional(),
|
|
111
|
+
/** IDE / agent-development environment hosting the actor, e.g. "orca". Deliberately distinct from `system` (the tool
|
|
112
|
+
* that produced the event, "claude-code") and from `client` (which build of it): the IDE is the app AROUND both, and
|
|
113
|
+
* neither of the other two can express it. Only stamped when the IDE identifies itself in the environment. */
|
|
114
|
+
ide: z.string().optional(),
|
|
115
|
+
/** Isolated workspace within `ide` — an Orca worktree id, a codespace or devcontainer name. This is what tells two
|
|
116
|
+
* parallel agents apart when they run the same project, on the same host, as the same actor. */
|
|
117
|
+
workspace: z.string().optional(),
|
|
118
|
+
/** Whether the producing process had a controlling terminal: "tty" = a human at a keyboard, "agent" = spawned by a
|
|
119
|
+
* harness with none. Linux-only today (read from /proc/self/stat); absent everywhere else, and absence is a legal
|
|
120
|
+
* permanent state. EVIDENCE, never authority — it must not override the actor determination. */
|
|
121
|
+
surface: z.enum(["tty", "agent"]).optional(),
|
|
122
|
+
});
|
|
123
|
+
export const Method = z.object({
|
|
124
|
+
tool: z.string().optional(), // e.g. "Edit", "git commit", "gdocs-ui"
|
|
125
|
+
/** Reference to instruction/prompt that drove this (id, hash, or short text) */
|
|
126
|
+
instruction: z.string().optional(),
|
|
127
|
+
params: z.record(z.unknown()).optional(),
|
|
128
|
+
automated: z.boolean().optional(),
|
|
129
|
+
tokens: z.number().int().nonnegative().optional(),
|
|
130
|
+
cost_usd: z.number().nonnegative().optional(),
|
|
131
|
+
});
|
|
132
|
+
/** What a client submits. Server fills in id/seq/hash/prev_hash. */
|
|
133
|
+
export const EventInput = z.object({
|
|
134
|
+
project: z.string().min(1),
|
|
135
|
+
actor: Actor,
|
|
136
|
+
action: Action,
|
|
137
|
+
action_detail: z.string().optional(),
|
|
138
|
+
artifacts: z.array(ArtifactRef).min(1),
|
|
139
|
+
change: Change.optional(),
|
|
140
|
+
timestamp: z.string().datetime({ offset: true }).optional(),
|
|
141
|
+
duration_ms: z.number().int().nonnegative().optional(),
|
|
142
|
+
location: Location.optional(),
|
|
143
|
+
/** WHY — free text reason */
|
|
144
|
+
intent: z.string().optional(),
|
|
145
|
+
/** WHY — causal parent (event id). The instruction that led to this action. */
|
|
146
|
+
caused_by: z.string().optional(),
|
|
147
|
+
method: Method.optional(),
|
|
148
|
+
/** Client-provided idempotency key */
|
|
149
|
+
idempotency_key: z.string().optional(),
|
|
150
|
+
tags: z.array(z.string()).optional(),
|
|
151
|
+
});
|
|
152
|
+
export const Event = EventInput.extend({
|
|
153
|
+
id: z.string().min(1),
|
|
154
|
+
seq: z.number().int().nonnegative(),
|
|
155
|
+
timestamp: z.string().datetime({ offset: true }),
|
|
156
|
+
prev_hash: z.string(),
|
|
157
|
+
hash: z.string(),
|
|
158
|
+
received_at: z.string().datetime({ offset: true }),
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* The schema surface a build understands, derived from the zod shapes themselves so it can never drift from the code.
|
|
162
|
+
*
|
|
163
|
+
* This exists because the failure it detects is SILENT: `POST /events` re-parses with `EventInput.safeParse`, and zod
|
|
164
|
+
* strips keys it does not know, so a producer running newer code than the deployment loses those fields with no error
|
|
165
|
+
* anywhere — the event is accepted, sealed and hashed without them. It has happened twice (`location.session`,
|
|
166
|
+
* `bacabed`; `location.client`/`ide`/`workspace`/`surface`, 2026-08-28), both times found by eye.
|
|
167
|
+
* `GET /api` publishes this, and `npm run check-deploy` diffs a deployment against the local build.
|
|
168
|
+
*/
|
|
169
|
+
export function schemaSurface() {
|
|
170
|
+
return {
|
|
171
|
+
event: Object.keys(EventInput.shape).sort(),
|
|
172
|
+
location: Object.keys(Location.shape).sort(),
|
|
173
|
+
artifact: Object.keys(ArtifactRef.shape).sort(),
|
|
174
|
+
actions: [...Action.options].sort(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export const GENESIS_HASH = "0".repeat(64);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface KeyPairJwk {
|
|
2
|
+
privateKey: JsonWebKey;
|
|
3
|
+
publicKey: JsonWebKey;
|
|
4
|
+
kid: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function generateSigningKey(): Promise<KeyPairJwk>;
|
|
7
|
+
/** kid = first 16 hex of sha256(canonical public jwk {crv,kty,x}) */
|
|
8
|
+
export declare function keyId(pub: JsonWebKey): Promise<string>;
|
|
9
|
+
export declare function publicFromPrivate(priv: JsonWebKey): JsonWebKey;
|
|
10
|
+
export declare function signCanonical(priv: JsonWebKey, value: unknown): Promise<string>;
|
|
11
|
+
export declare function verifyCanonical(pub: JsonWebKey, value: unknown, signature: string): Promise<boolean>;
|
|
12
|
+
/** Parse RETRACE_SIGNING_KEY (JSON JWK, or base64 of it). Returns null when unset. */
|
|
13
|
+
export declare function parseSigningKey(raw?: string | null): JsonWebKey | null;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 signing via WebCrypto (Node 22+, Cloudflare Workers, modern browsers).
|
|
3
|
+
* Keys are JWKs. Private key is passed as JSON in RETRACE_SIGNING_KEY; the public JWK is published at /.well-known/retrace-pubkey.
|
|
4
|
+
*/
|
|
5
|
+
import { canonicalize, sha256Hex } from "./chain.js";
|
|
6
|
+
const subtle = globalThis.crypto.subtle;
|
|
7
|
+
const ALG = { name: "Ed25519" };
|
|
8
|
+
export async function generateSigningKey() {
|
|
9
|
+
const kp = (await subtle.generateKey(ALG, true, ["sign", "verify"]));
|
|
10
|
+
const privateKey = await subtle.exportKey("jwk", kp.privateKey);
|
|
11
|
+
const publicKey = await subtle.exportKey("jwk", kp.publicKey);
|
|
12
|
+
const kid = await keyId(publicKey);
|
|
13
|
+
return { privateKey: { ...privateKey, kid }, publicKey: { ...publicKey, kid }, kid };
|
|
14
|
+
}
|
|
15
|
+
/** kid = first 16 hex of sha256(canonical public jwk {crv,kty,x}) */
|
|
16
|
+
export async function keyId(pub) {
|
|
17
|
+
return (await sha256Hex(canonicalize({ crv: pub.crv, kty: pub.kty, x: pub.x }))).slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
export function publicFromPrivate(priv) {
|
|
20
|
+
const { d: _d, key_ops: _k, ...pub } = priv;
|
|
21
|
+
return { ...pub, key_ops: ["verify"] };
|
|
22
|
+
}
|
|
23
|
+
const b64u = {
|
|
24
|
+
enc: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
|
|
25
|
+
dec: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=")), (c) => c.charCodeAt(0)),
|
|
26
|
+
};
|
|
27
|
+
export async function signCanonical(priv, value) {
|
|
28
|
+
const key = await subtle.importKey("jwk", priv, ALG, false, ["sign"]);
|
|
29
|
+
const sig = await subtle.sign(ALG, key, new TextEncoder().encode(canonicalize(value)));
|
|
30
|
+
return b64u.enc(sig);
|
|
31
|
+
}
|
|
32
|
+
export async function verifyCanonical(pub, value, signature) {
|
|
33
|
+
try {
|
|
34
|
+
const key = await subtle.importKey("jwk", { ...pub, key_ops: ["verify"] }, ALG, false, ["verify"]);
|
|
35
|
+
return await subtle.verify(ALG, key, b64u.dec(signature), new TextEncoder().encode(canonicalize(value)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Parse RETRACE_SIGNING_KEY (JSON JWK, or base64 of it). Returns null when unset. */
|
|
42
|
+
export function parseSigningKey(raw) {
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
const s = raw.trim();
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(s);
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(atob(s));
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
throw new Error("RETRACE_SIGNING_KEY is not a JWK (JSON or base64 JSON)");
|
|
55
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Event } from "./schema.js";
|
|
2
|
+
import { VerifyResult } from "./chain.js";
|
|
3
|
+
import { EventStore } from "./store.js";
|
|
4
|
+
export type StatusActor = {
|
|
5
|
+
type: Event["actor"]["type"];
|
|
6
|
+
id: string;
|
|
7
|
+
events: number;
|
|
8
|
+
last_seen: string;
|
|
9
|
+
models: string[];
|
|
10
|
+
};
|
|
11
|
+
export type StatusIntegration = {
|
|
12
|
+
system: string;
|
|
13
|
+
events: number;
|
|
14
|
+
last_seen: string;
|
|
15
|
+
};
|
|
16
|
+
export type ProjectStatus = {
|
|
17
|
+
project: string;
|
|
18
|
+
generated_at: string;
|
|
19
|
+
integrity: VerifyResult;
|
|
20
|
+
events: {
|
|
21
|
+
total: number;
|
|
22
|
+
last_event_at?: string;
|
|
23
|
+
};
|
|
24
|
+
capture: {
|
|
25
|
+
artifact_refs: number;
|
|
26
|
+
artifact_refs_without_role: number;
|
|
27
|
+
agent_events: number;
|
|
28
|
+
agent_events_without_model: number;
|
|
29
|
+
instructions: number;
|
|
30
|
+
instructions_without_followup: number;
|
|
31
|
+
commits: number;
|
|
32
|
+
unlinked_commits: number;
|
|
33
|
+
};
|
|
34
|
+
causality: {
|
|
35
|
+
eligible_events: number;
|
|
36
|
+
rooted_in_human_instruction: number;
|
|
37
|
+
broken_links: number;
|
|
38
|
+
unlinked: number;
|
|
39
|
+
coverage_pct: number;
|
|
40
|
+
};
|
|
41
|
+
actors: StatusActor[];
|
|
42
|
+
integrations: StatusIntegration[];
|
|
43
|
+
};
|
|
44
|
+
type RootState = "rooted" | "broken" | "unlinked";
|
|
45
|
+
/** Does this event's caused_by chain terminate at a human instruction root? */
|
|
46
|
+
export declare function causalRootState(event: Event, byId: Map<string, Event>): RootState;
|
|
47
|
+
export declare function buildProjectStatus(store: EventStore, project: string, now?: Date): Promise<ProjectStatus>;
|
|
48
|
+
export declare function renderProjectStatus(s: ProjectStatus): string;
|
|
49
|
+
export {};
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { verifyProject } from "./store.js";
|
|
2
|
+
/** Does this event's caused_by chain terminate at a human instruction root? */
|
|
3
|
+
export function causalRootState(event, byId) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
let cur = event;
|
|
6
|
+
while (cur) {
|
|
7
|
+
if (seen.has(cur.id))
|
|
8
|
+
return "broken";
|
|
9
|
+
seen.add(cur.id);
|
|
10
|
+
if (cur.actor.type === "human" && cur.action === "instructed")
|
|
11
|
+
return "rooted";
|
|
12
|
+
if (!cur.caused_by)
|
|
13
|
+
return "unlinked";
|
|
14
|
+
cur = byId.get(cur.caused_by);
|
|
15
|
+
if (!cur)
|
|
16
|
+
return "broken";
|
|
17
|
+
}
|
|
18
|
+
return "broken";
|
|
19
|
+
}
|
|
20
|
+
export async function buildProjectStatus(store, project, now = new Date()) {
|
|
21
|
+
const events = await store.all(project);
|
|
22
|
+
const integrity = await verifyProject(store, project);
|
|
23
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
24
|
+
const caused = new Set(events.map((e) => e.caused_by).filter((x) => !!x));
|
|
25
|
+
const eligible = events.filter((e) => e.actor.type === "agent" || e.action === "committed" || e.action === "merged");
|
|
26
|
+
const roots = eligible.map((e) => causalRootState(e, byId));
|
|
27
|
+
const count = (s) => roots.filter((x) => x === s).length;
|
|
28
|
+
const actors = new Map();
|
|
29
|
+
const integrations = new Map();
|
|
30
|
+
for (const e of events) {
|
|
31
|
+
const ak = `${e.actor.type}:${e.actor.id}`;
|
|
32
|
+
const actor = actors.get(ak) ?? { type: e.actor.type, id: e.actor.id, events: 0, last_seen: e.timestamp, models: [] };
|
|
33
|
+
actor.events++;
|
|
34
|
+
if (e.timestamp > actor.last_seen)
|
|
35
|
+
actor.last_seen = e.timestamp;
|
|
36
|
+
if (e.actor.model && !actor.models.includes(e.actor.model))
|
|
37
|
+
actor.models.push(e.actor.model);
|
|
38
|
+
actors.set(ak, actor);
|
|
39
|
+
const system = e.location?.system;
|
|
40
|
+
if (system) {
|
|
41
|
+
const integration = integrations.get(system) ?? { system, events: 0, last_seen: e.timestamp };
|
|
42
|
+
integration.events++;
|
|
43
|
+
if (e.timestamp > integration.last_seen)
|
|
44
|
+
integration.last_seen = e.timestamp;
|
|
45
|
+
integrations.set(system, integration);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const artifactRefs = events.flatMap((e) => e.artifacts);
|
|
49
|
+
const instructions = events.filter((e) => e.actor.type === "human" && e.action === "instructed");
|
|
50
|
+
const commits = events.filter((e) => e.action === "committed" || e.action === "merged");
|
|
51
|
+
const rooted = count("rooted");
|
|
52
|
+
return {
|
|
53
|
+
project,
|
|
54
|
+
generated_at: now.toISOString(),
|
|
55
|
+
integrity,
|
|
56
|
+
events: { total: events.length, last_event_at: events.at(-1)?.timestamp },
|
|
57
|
+
capture: {
|
|
58
|
+
artifact_refs: artifactRefs.length,
|
|
59
|
+
artifact_refs_without_role: artifactRefs.filter((a) => a.role === undefined).length,
|
|
60
|
+
agent_events: events.filter((e) => e.actor.type === "agent").length,
|
|
61
|
+
agent_events_without_model: events.filter((e) => e.actor.type === "agent" && !e.actor.model).length,
|
|
62
|
+
instructions: instructions.length,
|
|
63
|
+
instructions_without_followup: instructions.filter((e) => !caused.has(e.id)).length,
|
|
64
|
+
commits: commits.length,
|
|
65
|
+
unlinked_commits: commits.filter((e) => causalRootState(e, byId) !== "rooted").length,
|
|
66
|
+
},
|
|
67
|
+
causality: {
|
|
68
|
+
eligible_events: eligible.length,
|
|
69
|
+
rooted_in_human_instruction: rooted,
|
|
70
|
+
broken_links: count("broken"),
|
|
71
|
+
unlinked: count("unlinked"),
|
|
72
|
+
coverage_pct: eligible.length ? Math.round(rooted * 1000 / eligible.length) / 10 : 100,
|
|
73
|
+
},
|
|
74
|
+
actors: [...actors.values()].map((a) => ({ ...a, models: a.models.sort() })).sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id)),
|
|
75
|
+
integrations: [...integrations.values()].sort((a, b) => a.system.localeCompare(b.system)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function renderProjectStatus(s) {
|
|
79
|
+
const health = s.integrity.ok ? "VERIFIED" : "BROKEN";
|
|
80
|
+
return `${s.project} — ${health}\n` +
|
|
81
|
+
`${s.events.total} events · ${s.causality.coverage_pct}% causal coverage · ${s.capture.unlinked_commits}/${s.capture.commits} unlinked commits\n` +
|
|
82
|
+
`${s.capture.agent_events_without_model}/${s.capture.agent_events} agent events missing model · ${s.capture.instructions_without_followup}/${s.capture.instructions} instructions without follow-up · ${s.capture.artifact_refs_without_role}/${s.capture.artifact_refs} artifact refs missing role\n` +
|
|
83
|
+
`actors: ${s.actors.map((a) => `${a.type}/${a.id} (${a.events})`).join(", ") || "none"}\n` +
|
|
84
|
+
`integrations: ${s.integrations.map((i) => `${i.system} (${i.events}, last ${i.last_seen})`).join(", ") || "none"}`;
|
|
85
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage interface — implemented by SQLite (MCP server) and D1 (Worker).
|
|
3
|
+
* SQL schema shared by both lives in SCHEMA_SQL.
|
|
4
|
+
*/
|
|
5
|
+
import { Event, EventInput } from "./schema.js";
|
|
6
|
+
import { VerifyResult } from "./chain.js";
|
|
7
|
+
export interface HistoryQuery {
|
|
8
|
+
project: string;
|
|
9
|
+
artifact_id?: string;
|
|
10
|
+
actor_id?: string;
|
|
11
|
+
actor_type?: string;
|
|
12
|
+
action?: string;
|
|
13
|
+
since?: string;
|
|
14
|
+
until?: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface Share {
|
|
19
|
+
id: string;
|
|
20
|
+
project: string;
|
|
21
|
+
artifact_id?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
created_at: string;
|
|
24
|
+
expires_at?: string;
|
|
25
|
+
created_by?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface EventStore {
|
|
28
|
+
head(project: string): Promise<ChainHead | null>;
|
|
29
|
+
createShare(share: Share): Promise<void>;
|
|
30
|
+
getShare(id: string): Promise<Share | null>;
|
|
31
|
+
insert(e: Event): Promise<void>;
|
|
32
|
+
byIdempotencyKey(project: string, key: string): Promise<Event | null>;
|
|
33
|
+
get(id: string): Promise<Event | null>;
|
|
34
|
+
history(q: HistoryQuery): Promise<Event[]>;
|
|
35
|
+
all(project: string): Promise<Event[]>;
|
|
36
|
+
projects(): Promise<string[]>;
|
|
37
|
+
/** Delete every row belonging to a project AND insert `audit` (already sealed onto its own project's chain) in the
|
|
38
|
+
* same transaction, so a deletion can never exist without its audit record and vice versa (security review
|
|
39
|
+
* 2026-08-21, B3). `expectedHead` is the target project's head the caller sealed the audit's `change` fields from:
|
|
40
|
+
* the store must check INSIDE the transaction that the head is still exactly that and throw `HeadMovedError`
|
|
41
|
+
* (committing nothing) if a write raced the delete, so the immortal audit record can never describe a head or
|
|
42
|
+
* event count other than the one actually deleted. Returns per-table deleted counts. Optional — stores without it
|
|
43
|
+
* don't serve DELETE /projects/:p. */
|
|
44
|
+
deleteProject?(project: string, audit: Event, expectedHead: ChainHead): Promise<Record<string, number>>;
|
|
45
|
+
}
|
|
46
|
+
export type ChainHead = {
|
|
47
|
+
seq: number;
|
|
48
|
+
hash: string;
|
|
49
|
+
};
|
|
50
|
+
/** Thrown by `deleteProject` when the target project's head no longer matches `expectedHead`: nothing was committed;
|
|
51
|
+
* re-read the head, re-seal the audit and retry. */
|
|
52
|
+
export declare class HeadMovedError extends Error {
|
|
53
|
+
constructor(project: string, expected: ChainHead);
|
|
54
|
+
}
|
|
55
|
+
export declare function isHeadMovedError(e: unknown): e is HeadMovedError;
|
|
56
|
+
export declare const SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS events (\n id TEXT PRIMARY KEY,\n project TEXT NOT NULL,\n seq INTEGER NOT NULL,\n timestamp TEXT NOT NULL,\n received_at TEXT NOT NULL,\n actor_type TEXT NOT NULL,\n actor_id TEXT NOT NULL,\n action TEXT NOT NULL,\n caused_by TEXT,\n idempotency_key TEXT,\n prev_hash TEXT NOT NULL,\n hash TEXT NOT NULL,\n body TEXT NOT NULL,\n UNIQUE(project, seq)\n);\nCREATE INDEX IF NOT EXISTS idx_events_project_ts ON events(project, timestamp);\nCREATE INDEX IF NOT EXISTS idx_events_actor ON events(project, actor_id);\nCREATE INDEX IF NOT EXISTS idx_events_idem ON events(project, idempotency_key);\nCREATE TABLE IF NOT EXISTS event_artifacts (\n event_id TEXT NOT NULL,\n project TEXT NOT NULL,\n artifact_id TEXT NOT NULL,\n PRIMARY KEY (event_id, artifact_id)\n);\nCREATE INDEX IF NOT EXISTS idx_ea_artifact ON event_artifacts(project, artifact_id);\nCREATE TABLE IF NOT EXISTS shares (\n id TEXT PRIMARY KEY,\n project TEXT NOT NULL,\n artifact_id TEXT,\n label TEXT,\n created_at TEXT NOT NULL,\n expires_at TEXT,\n created_by TEXT\n);\n";
|
|
57
|
+
export declare function newShareId(): string;
|
|
58
|
+
export declare function shareIsLive(s: Share, now?: Date): boolean;
|
|
59
|
+
/** Append an event: idempotent, sealed onto the current chain head. */
|
|
60
|
+
export declare function appendEvent(store: EventStore, input: EventInput): Promise<{
|
|
61
|
+
event: Event;
|
|
62
|
+
deduped: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
export declare function verifyProject(store: EventStore, project: string): Promise<VerifyResult>;
|
|
65
|
+
/** Walk caused_by links up to the root — the "why" chain. */
|
|
66
|
+
export declare function explainEvent(store: EventStore, id: string, maxDepth?: number): Promise<Event[]>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { sealEvent, verifyChain } from "./chain.js";
|
|
2
|
+
/** Thrown by `deleteProject` when the target project's head no longer matches `expectedHead`: nothing was committed;
|
|
3
|
+
* re-read the head, re-seal the audit and retry. */
|
|
4
|
+
export class HeadMovedError extends Error {
|
|
5
|
+
constructor(project, expected) {
|
|
6
|
+
super(`project "${project}" was written to while being deleted (expected head ${expected.hash} seq ${expected.seq}); re-read its head and retry`);
|
|
7
|
+
this.name = "HeadMovedError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function isHeadMovedError(e) {
|
|
11
|
+
return e instanceof HeadMovedError || e?.name === "HeadMovedError";
|
|
12
|
+
}
|
|
13
|
+
export const SCHEMA_SQL = `
|
|
14
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
project TEXT NOT NULL,
|
|
17
|
+
seq INTEGER NOT NULL,
|
|
18
|
+
timestamp TEXT NOT NULL,
|
|
19
|
+
received_at TEXT NOT NULL,
|
|
20
|
+
actor_type TEXT NOT NULL,
|
|
21
|
+
actor_id TEXT NOT NULL,
|
|
22
|
+
action TEXT NOT NULL,
|
|
23
|
+
caused_by TEXT,
|
|
24
|
+
idempotency_key TEXT,
|
|
25
|
+
prev_hash TEXT NOT NULL,
|
|
26
|
+
hash TEXT NOT NULL,
|
|
27
|
+
body TEXT NOT NULL,
|
|
28
|
+
UNIQUE(project, seq)
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_events_project_ts ON events(project, timestamp);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_events_actor ON events(project, actor_id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_events_idem ON events(project, idempotency_key);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS event_artifacts (
|
|
34
|
+
event_id TEXT NOT NULL,
|
|
35
|
+
project TEXT NOT NULL,
|
|
36
|
+
artifact_id TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (event_id, artifact_id)
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_ea_artifact ON event_artifacts(project, artifact_id);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS shares (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
project TEXT NOT NULL,
|
|
43
|
+
artifact_id TEXT,
|
|
44
|
+
label TEXT,
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
expires_at TEXT,
|
|
47
|
+
created_by TEXT
|
|
48
|
+
);
|
|
49
|
+
`;
|
|
50
|
+
export function newShareId() {
|
|
51
|
+
const bytes = new Uint8Array(12);
|
|
52
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
53
|
+
return "sh_" + [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
54
|
+
}
|
|
55
|
+
export function shareIsLive(s, now = new Date()) {
|
|
56
|
+
return !s.expires_at || new Date(s.expires_at) > now;
|
|
57
|
+
}
|
|
58
|
+
/** Append an event: idempotent, sealed onto the current chain head. */
|
|
59
|
+
export async function appendEvent(store, input) {
|
|
60
|
+
if (input.idempotency_key) {
|
|
61
|
+
const existing = await store.byIdempotencyKey(input.project, input.idempotency_key);
|
|
62
|
+
if (existing)
|
|
63
|
+
return { event: existing, deduped: true };
|
|
64
|
+
}
|
|
65
|
+
const head = await store.head(input.project);
|
|
66
|
+
const event = await sealEvent(input, head);
|
|
67
|
+
await store.insert(event);
|
|
68
|
+
return { event, deduped: false };
|
|
69
|
+
}
|
|
70
|
+
export async function verifyProject(store, project) {
|
|
71
|
+
return verifyChain(await store.all(project));
|
|
72
|
+
}
|
|
73
|
+
/** Walk caused_by links up to the root — the "why" chain. */
|
|
74
|
+
export async function explainEvent(store, id, maxDepth = 25) {
|
|
75
|
+
const chain = [];
|
|
76
|
+
let cur = await store.get(id);
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
while (cur && chain.length < maxDepth && !seen.has(cur.id)) {
|
|
79
|
+
seen.add(cur.id);
|
|
80
|
+
chain.push(cur);
|
|
81
|
+
cur = cur.caused_by ? await store.get(cur.caused_by) : null;
|
|
82
|
+
}
|
|
83
|
+
return chain;
|
|
84
|
+
}
|