@coframe-gtm/annotations 1.0.1
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 +48 -0
- package/package.json +43 -0
- package/src/README.md +79 -0
- package/src/api.test.ts +253 -0
- package/src/api.ts +264 -0
- package/src/bundle.ts +69 -0
- package/src/capture.test.ts +88 -0
- package/src/capture.ts +345 -0
- package/src/index.ts +45 -0
- package/src/inject/build.ts +52 -0
- package/src/inject/bundle-source.generated.ts +5 -0
- package/src/inject/install.test.ts +84 -0
- package/src/inject/install.ts +126 -0
- package/src/output.ts +171 -0
- package/src/picker.ts +203 -0
- package/src/server/index.ts +28 -0
- package/src/server/ingest.test.ts +144 -0
- package/src/server/ingest.ts +175 -0
- package/src/server/run-store.test.ts +51 -0
- package/src/server/run-store.ts +155 -0
- package/src/shadow.ts +84 -0
- package/src/store.ts +79 -0
- package/src/types.ts +154 -0
- package/src/ui/App.ts +516 -0
- package/src/ui/styles.ts +283 -0
- package/src/ulid.ts +21 -0
- package/src/webhook.ts +33 -0
package/src/picker.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction controller — the human side of annotation capture.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle is driven by the `mode` / `feedbackTool` signals:
|
|
5
|
+
*
|
|
6
|
+
* - mode === "feedback" + tool "element" → hover-highlight, click
|
|
7
|
+
* pins a single element and opens the composer.
|
|
8
|
+
* - tool "text" → native text selection; mouseup with a non-empty
|
|
9
|
+
* selection captures the anchor element + selectedText.
|
|
10
|
+
* - tool "multi" → click toggles elements into a set; the composer
|
|
11
|
+
* "add" affordance commits the set with `isMultiSelect`.
|
|
12
|
+
*
|
|
13
|
+
* The library's Shadow DOM host is `pointer-events: none` over empty
|
|
14
|
+
* space, so page elements receive pointer events normally and
|
|
15
|
+
* `event.target` is the real page element. Events whose composedPath
|
|
16
|
+
* includes our host are our own UI — ignored for picking.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { captureElement, type CapturedTarget } from "./capture.js";
|
|
20
|
+
import {
|
|
21
|
+
author,
|
|
22
|
+
draft,
|
|
23
|
+
feedbackTool,
|
|
24
|
+
hoverBox,
|
|
25
|
+
mode,
|
|
26
|
+
multiSelection,
|
|
27
|
+
} from "./store.js";
|
|
28
|
+
import { emit } from "./webhook.js";
|
|
29
|
+
|
|
30
|
+
let host: HTMLElement | null = null;
|
|
31
|
+
let installed = false;
|
|
32
|
+
|
|
33
|
+
// Throttle local-cursor presence so we emit at most ~8/s (multi-cursor).
|
|
34
|
+
let lastPresenceAt = 0;
|
|
35
|
+
function emitPresence(event: MouseEvent): void {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (now - lastPresenceAt < 120) return;
|
|
38
|
+
lastPresenceAt = now;
|
|
39
|
+
emit("presence.cursor", {
|
|
40
|
+
id: author.value.id ?? "human",
|
|
41
|
+
label: author.value.displayName ?? "You",
|
|
42
|
+
kind: author.value.kind,
|
|
43
|
+
x: (event.clientX / window.innerWidth) * 100,
|
|
44
|
+
y: event.clientY + window.scrollY,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Register the Shadow host so we can exclude our own UI from picks. */
|
|
49
|
+
export function setPickerHost(el: HTMLElement): void {
|
|
50
|
+
host = el;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isOwnUi(event: Event): boolean {
|
|
54
|
+
if (!host) return false;
|
|
55
|
+
const path = event.composedPath();
|
|
56
|
+
return path.includes(host);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pickable(target: EventTarget | null): Element | null {
|
|
60
|
+
if (!(target instanceof Element)) return null;
|
|
61
|
+
if (target.tagName === "HTML" || target.tagName === "BODY") return null;
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onMouseMove(event: MouseEvent): void {
|
|
66
|
+
// Always broadcast the local cursor (presence) — independent of mode —
|
|
67
|
+
// so the host can mirror it to other actors for multi-cursor.
|
|
68
|
+
if (!isOwnUi(event)) emitPresence(event);
|
|
69
|
+
if (mode.value !== "feedback" || draft.value) {
|
|
70
|
+
hoverBox.value = null;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (isOwnUi(event)) {
|
|
74
|
+
hoverBox.value = null;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (feedbackTool.value === "text") {
|
|
78
|
+
hoverBox.value = null;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const el = pickable(event.target);
|
|
82
|
+
if (!el) {
|
|
83
|
+
hoverBox.value = null;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const rect = el.getBoundingClientRect();
|
|
87
|
+
hoverBox.value = {
|
|
88
|
+
top: rect.top,
|
|
89
|
+
left: rect.left,
|
|
90
|
+
width: rect.width,
|
|
91
|
+
height: rect.height,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onClick(event: MouseEvent): void {
|
|
96
|
+
if (mode.value !== "feedback") return;
|
|
97
|
+
if (isOwnUi(event)) return;
|
|
98
|
+
if (feedbackTool.value === "text") return; // handled on mouseup
|
|
99
|
+
if (draft.value) return; // composer open — ignore page clicks
|
|
100
|
+
|
|
101
|
+
const el = pickable(event.target);
|
|
102
|
+
if (!el) return;
|
|
103
|
+
|
|
104
|
+
// Suppress the page's own click handling while annotating.
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
event.stopPropagation();
|
|
107
|
+
|
|
108
|
+
if (feedbackTool.value === "multi") {
|
|
109
|
+
toggleMulti(el);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Single element → open composer immediately.
|
|
114
|
+
openComposer(captureElement(el));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onMouseUp(): void {
|
|
118
|
+
if (mode.value !== "feedback" || feedbackTool.value !== "text") return;
|
|
119
|
+
if (draft.value) return;
|
|
120
|
+
const selection = window.getSelection();
|
|
121
|
+
const text = selection?.toString().trim();
|
|
122
|
+
if (!text || !selection || selection.rangeCount === 0) return;
|
|
123
|
+
const range = selection.getRangeAt(0);
|
|
124
|
+
const anchor =
|
|
125
|
+
range.commonAncestorContainer.nodeType === 1
|
|
126
|
+
? (range.commonAncestorContainer as Element)
|
|
127
|
+
: range.commonAncestorContainer.parentElement;
|
|
128
|
+
if (!anchor) return;
|
|
129
|
+
const captured = captureElement(anchor);
|
|
130
|
+
openComposer({ ...captured, selectedText: text });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
134
|
+
if (event.key !== "Escape") return;
|
|
135
|
+
if (draft.value) {
|
|
136
|
+
draft.value = null;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (multiSelection.value.length) {
|
|
140
|
+
multiSelection.value = [];
|
|
141
|
+
hoverBox.value = null;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (mode.value === "feedback") {
|
|
145
|
+
mode.value = "view";
|
|
146
|
+
hoverBox.value = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toggleMulti(el: Element): void {
|
|
151
|
+
const current = multiSelection.value;
|
|
152
|
+
multiSelection.value = current.includes(el)
|
|
153
|
+
? current.filter((e) => e !== el)
|
|
154
|
+
: [...current, el];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Commit the current multi-element set into a single draft. */
|
|
158
|
+
export function commitMultiSelection(): void {
|
|
159
|
+
const els = multiSelection.value;
|
|
160
|
+
if (!els.length) return;
|
|
161
|
+
const primary = captureElement(els[0]!);
|
|
162
|
+
const boxes = els.map((el) => {
|
|
163
|
+
const rect = el.getBoundingClientRect();
|
|
164
|
+
return {
|
|
165
|
+
x: rect.left + window.scrollX,
|
|
166
|
+
y: rect.top + window.scrollY,
|
|
167
|
+
width: rect.width,
|
|
168
|
+
height: rect.height,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
const labels = els.map((el) => el.tagName.toLowerCase()).join(", ");
|
|
172
|
+
openComposer({
|
|
173
|
+
...primary,
|
|
174
|
+
isMultiSelect: true,
|
|
175
|
+
elementBoundingBoxes: boxes,
|
|
176
|
+
nearbyElements: `${els.length} elements: ${labels}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function openComposer(target: CapturedTarget): void {
|
|
181
|
+
multiSelection.value = [];
|
|
182
|
+
hoverBox.value = null;
|
|
183
|
+
draft.value = target;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Attach document listeners once. Idempotent. */
|
|
187
|
+
export function installPicker(): void {
|
|
188
|
+
if (installed) return;
|
|
189
|
+
installed = true;
|
|
190
|
+
document.addEventListener("mousemove", onMouseMove, true);
|
|
191
|
+
document.addEventListener("click", onClick, true);
|
|
192
|
+
document.addEventListener("mouseup", onMouseUp, true);
|
|
193
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function uninstallPicker(): void {
|
|
197
|
+
if (!installed) return;
|
|
198
|
+
installed = false;
|
|
199
|
+
document.removeEventListener("mousemove", onMouseMove, true);
|
|
200
|
+
document.removeEventListener("click", onClick, true);
|
|
201
|
+
document.removeEventListener("mouseup", onMouseUp, true);
|
|
202
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
203
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable backend surface — the transport-agnostic ingest core.
|
|
3
|
+
*
|
|
4
|
+
* Wrap these in a platform adapter:
|
|
5
|
+
* - Cloudflare Worker route, or
|
|
6
|
+
* - AWS Lambda / Function URL.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here imports a platform SDK or any vendor-internal package, so the
|
|
9
|
+
* folder copies cleanly into another repo alongside `../types.ts`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
annotationQueueReducer,
|
|
14
|
+
applyAnnotationEvent,
|
|
15
|
+
corsHeaders,
|
|
16
|
+
emptyAnnotationsState,
|
|
17
|
+
parseEnvelope,
|
|
18
|
+
} from "./ingest.js";
|
|
19
|
+
|
|
20
|
+
export type { AnnotationsState } from "./ingest.js";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
InMemoryRunStore,
|
|
24
|
+
makeRunRecord,
|
|
25
|
+
recordAnnotationEventRun,
|
|
26
|
+
} from "./run-store.js";
|
|
27
|
+
|
|
28
|
+
export type { RunStore, RunRecord, RunKind } from "./run-store.js";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest core tests. Pure logic, no DOM/platform — runs in the default
|
|
3
|
+
* node env. This core is the portable "backend" shared by the the host app
|
|
4
|
+
* Cloudflare route and a future GTM-repo AWS Lambda.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
annotationQueueReducer,
|
|
11
|
+
applyAnnotationEvent,
|
|
12
|
+
corsHeaders,
|
|
13
|
+
emptyAnnotationsState,
|
|
14
|
+
parseEnvelope,
|
|
15
|
+
type AnnotationsState,
|
|
16
|
+
} from "./ingest.js";
|
|
17
|
+
import type { AgentationEvent, Annotation } from "../types.js";
|
|
18
|
+
|
|
19
|
+
function ann(id: string, over: Partial<Annotation> = {}): Annotation {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
comment: "c",
|
|
23
|
+
elementPath: "button.cta",
|
|
24
|
+
element: "button",
|
|
25
|
+
x: 10,
|
|
26
|
+
y: 20,
|
|
27
|
+
timestamp: 0,
|
|
28
|
+
status: "pending",
|
|
29
|
+
thread: [],
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function envelope(
|
|
35
|
+
type: AgentationEvent["type"],
|
|
36
|
+
payload: unknown,
|
|
37
|
+
seq = 1,
|
|
38
|
+
): AgentationEvent {
|
|
39
|
+
return {
|
|
40
|
+
type,
|
|
41
|
+
timestamp: "2026-05-29T00:00:00.000Z",
|
|
42
|
+
sessionId: "cf_sid",
|
|
43
|
+
sequence: seq,
|
|
44
|
+
payload,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("parseEnvelope", () => {
|
|
49
|
+
it("accepts a well-formed envelope", () => {
|
|
50
|
+
const e = parseEnvelope({
|
|
51
|
+
type: "annotation.created",
|
|
52
|
+
timestamp: "2026-05-29T00:00:00.000Z",
|
|
53
|
+
sessionId: "s",
|
|
54
|
+
sequence: 1,
|
|
55
|
+
payload: ann("a1"),
|
|
56
|
+
});
|
|
57
|
+
expect(e.type).toBe("annotation.created");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
["not an object", 42],
|
|
62
|
+
["bad type", { type: "nope", timestamp: "t", sessionId: "s", sequence: 1, payload: {} }],
|
|
63
|
+
["missing payload", { type: "annotation.created", timestamp: "t", sessionId: "s", sequence: 1 }],
|
|
64
|
+
["bad sequence", { type: "session.updated", timestamp: "t", sessionId: "s", sequence: "x", payload: {} }],
|
|
65
|
+
])("rejects %s", (_label, body) => {
|
|
66
|
+
expect(() => parseEnvelope(body)).toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("annotationQueueReducer", () => {
|
|
71
|
+
it("created pushes; duplicate id replaces", () => {
|
|
72
|
+
let s = emptyAnnotationsState();
|
|
73
|
+
s = annotationQueueReducer(s, envelope("annotation.created", ann("a1", { comment: "first" })));
|
|
74
|
+
s = annotationQueueReducer(s, envelope("annotation.created", ann("a1", { comment: "second" }), 2));
|
|
75
|
+
expect(s.items).toHaveLength(1);
|
|
76
|
+
expect(s.items[0]!.comment).toBe("second");
|
|
77
|
+
expect(s.lastSequence).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("updated patches by id, no-op when absent", () => {
|
|
81
|
+
let s: AnnotationsState = { items: [ann("a1")] };
|
|
82
|
+
s = annotationQueueReducer(s, envelope("annotation.updated", { id: "a1", patch: { status: "resolved" } }));
|
|
83
|
+
expect(s.items[0]!.status).toBe("resolved");
|
|
84
|
+
const before = s;
|
|
85
|
+
s = annotationQueueReducer(s, envelope("annotation.updated", { id: "ghost", patch: { status: "dismissed" } }));
|
|
86
|
+
expect(s.items).toEqual(before.items);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("deleted removes by id", () => {
|
|
90
|
+
let s: AnnotationsState = { items: [ann("a1"), ann("a2")] };
|
|
91
|
+
s = annotationQueueReducer(s, envelope("annotation.deleted", { id: "a1" }));
|
|
92
|
+
expect(s.items.map((a) => a.id)).toEqual(["a2"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("thread.message appends to the right annotation", () => {
|
|
96
|
+
let s: AnnotationsState = { items: [ann("a1")] };
|
|
97
|
+
s = annotationQueueReducer(
|
|
98
|
+
s,
|
|
99
|
+
envelope("thread.message", {
|
|
100
|
+
annotationId: "a1",
|
|
101
|
+
message: { id: "m1", role: "agent", content: "hi", timestamp: 1 },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
expect(s.items[0]!.thread).toHaveLength(1);
|
|
105
|
+
expect(s.items[0]!.thread![0]!.content).toBe("hi");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("session.* only bumps metadata", () => {
|
|
109
|
+
const s = annotationQueueReducer(
|
|
110
|
+
{ items: [ann("a1")] },
|
|
111
|
+
envelope("session.updated", { mode: "feedback" }, 7),
|
|
112
|
+
);
|
|
113
|
+
expect(s.items).toHaveLength(1);
|
|
114
|
+
expect(s.lastSequence).toBe(7);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("applyAnnotationEvent", () => {
|
|
119
|
+
it("reads, reduces, and writes back via the io adapter", async () => {
|
|
120
|
+
let state = emptyAnnotationsState();
|
|
121
|
+
const next = await applyAnnotationEvent(
|
|
122
|
+
{
|
|
123
|
+
getState: () => state,
|
|
124
|
+
setState: (n) => {
|
|
125
|
+
state = n;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
envelope("annotation.created", ann("a1")),
|
|
129
|
+
);
|
|
130
|
+
expect(next.items).toHaveLength(1);
|
|
131
|
+
expect(state.items).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("corsHeaders", () => {
|
|
136
|
+
it("reflects the origin and allows POST/OPTIONS", () => {
|
|
137
|
+
const h = corsHeaders("https://stripe.com");
|
|
138
|
+
expect(h["access-control-allow-origin"]).toBe("https://stripe.com");
|
|
139
|
+
expect(h["access-control-allow-methods"]).toContain("POST");
|
|
140
|
+
});
|
|
141
|
+
it("falls back to * when origin is null", () => {
|
|
142
|
+
expect(corsHeaders(null)["access-control-allow-origin"]).toBe("*");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic annotation ingest core (the "backend" layer).
|
|
3
|
+
*
|
|
4
|
+
* Zero platform dependencies — no Cloudflare, no AWS, no vendor-internal packages.
|
|
5
|
+
* Pure functions over the AFS event envelope so the SAME logic wraps a
|
|
6
|
+
* Cloudflare Worker route and an AWS Lambda (another
|
|
7
|
+
* engine repo). Host adapters supply `getState` / `setState`; this core
|
|
8
|
+
* owns parsing, the queue reducer, and CORS.
|
|
9
|
+
*
|
|
10
|
+
* Keep this file portable: it may be copied verbatim into another repo
|
|
11
|
+
* alongside `../types.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AgentationEvent,
|
|
16
|
+
AgentationEventType,
|
|
17
|
+
Annotation,
|
|
18
|
+
ThreadMessage,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
|
|
21
|
+
/** The host-held annotation queue. Broadcast to viewers + fed to the agent. */
|
|
22
|
+
export interface AnnotationsState {
|
|
23
|
+
readonly items: ReadonlyArray<Annotation>;
|
|
24
|
+
readonly lastEventAt?: number;
|
|
25
|
+
readonly lastSequence?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function emptyAnnotationsState(): AnnotationsState {
|
|
29
|
+
return { items: [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EVENT_TYPES: ReadonlySet<string> = new Set<AgentationEventType>([
|
|
33
|
+
"annotation.created",
|
|
34
|
+
"annotation.updated",
|
|
35
|
+
"annotation.deleted",
|
|
36
|
+
"session.created",
|
|
37
|
+
"session.updated",
|
|
38
|
+
"session.closed",
|
|
39
|
+
"thread.message",
|
|
40
|
+
"action.requested",
|
|
41
|
+
"presence.cursor",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate + narrow an untrusted request body into an AFS event
|
|
46
|
+
* envelope. Throws `Error` with a readable message on malformed input
|
|
47
|
+
* so the adapter can return 400.
|
|
48
|
+
*/
|
|
49
|
+
export function parseEnvelope(body: unknown): AgentationEvent {
|
|
50
|
+
if (typeof body !== "object" || body === null) {
|
|
51
|
+
throw new Error("envelope must be a JSON object");
|
|
52
|
+
}
|
|
53
|
+
const e = body as Record<string, unknown>;
|
|
54
|
+
if (typeof e.type !== "string" || !EVENT_TYPES.has(e.type)) {
|
|
55
|
+
throw new Error(`unknown or missing event type: ${String(e.type)}`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof e.timestamp !== "string") {
|
|
58
|
+
throw new Error("envelope.timestamp must be an ISO string");
|
|
59
|
+
}
|
|
60
|
+
if (typeof e.sessionId !== "string") {
|
|
61
|
+
throw new Error("envelope.sessionId must be a string");
|
|
62
|
+
}
|
|
63
|
+
if (typeof e.sequence !== "number") {
|
|
64
|
+
throw new Error("envelope.sequence must be a number");
|
|
65
|
+
}
|
|
66
|
+
if (!("payload" in e)) {
|
|
67
|
+
throw new Error("envelope.payload is required");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
type: e.type as AgentationEventType,
|
|
71
|
+
timestamp: e.timestamp,
|
|
72
|
+
sessionId: e.sessionId,
|
|
73
|
+
sequence: e.sequence,
|
|
74
|
+
payload: e.payload,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Apply one AFS event to the queue. Pure — returns a new state, never
|
|
80
|
+
* mutates. Out-of-order / duplicate events are tolerated: `created`
|
|
81
|
+
* replaces by id, `updated`/`deleted`/`thread.message` no-op when the
|
|
82
|
+
* target is absent.
|
|
83
|
+
*/
|
|
84
|
+
export function annotationQueueReducer(
|
|
85
|
+
state: AnnotationsState,
|
|
86
|
+
envelope: AgentationEvent,
|
|
87
|
+
): AnnotationsState {
|
|
88
|
+
const base: AnnotationsState = {
|
|
89
|
+
...state,
|
|
90
|
+
lastEventAt: Date.parse(envelope.timestamp) || state.lastEventAt,
|
|
91
|
+
lastSequence: envelope.sequence,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
switch (envelope.type) {
|
|
95
|
+
case "annotation.created": {
|
|
96
|
+
const ann = envelope.payload as Annotation;
|
|
97
|
+
if (!ann || typeof ann.id !== "string") return base;
|
|
98
|
+
const without = base.items.filter((a) => a.id !== ann.id);
|
|
99
|
+
return { ...base, items: [...without, ann] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case "annotation.updated": {
|
|
103
|
+
const { id, patch } = (envelope.payload ?? {}) as {
|
|
104
|
+
id?: string;
|
|
105
|
+
patch?: Partial<Annotation>;
|
|
106
|
+
};
|
|
107
|
+
if (!id || !patch) return base;
|
|
108
|
+
return {
|
|
109
|
+
...base,
|
|
110
|
+
items: base.items.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "annotation.deleted": {
|
|
115
|
+
const { id } = (envelope.payload ?? {}) as { id?: string };
|
|
116
|
+
if (!id) return base;
|
|
117
|
+
return { ...base, items: base.items.filter((a) => a.id !== id) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "thread.message": {
|
|
121
|
+
const { annotationId, message } = (envelope.payload ?? {}) as {
|
|
122
|
+
annotationId?: string;
|
|
123
|
+
message?: ThreadMessage;
|
|
124
|
+
};
|
|
125
|
+
if (!annotationId || !message) return base;
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
items: base.items.map((a) =>
|
|
129
|
+
a.id === annotationId
|
|
130
|
+
? { ...a, thread: [...(a.thread ?? []), message] }
|
|
131
|
+
: a,
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// session.* and action.requested carry no queue mutation; the
|
|
137
|
+
// metadata bump above (lastEventAt/lastSequence) is the whole effect.
|
|
138
|
+
default:
|
|
139
|
+
return base;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* CORS headers for the webhook response. The overlay runs on the target
|
|
145
|
+
* page origin and POSTs cross-origin to the host, so preflight + ACAO
|
|
146
|
+
* are mandatory. Reflects the request origin when present (credentials
|
|
147
|
+
* are not used, so `*` is also safe; reflecting is friendlier to future
|
|
148
|
+
* cookie-bearing setups).
|
|
149
|
+
*/
|
|
150
|
+
export function corsHeaders(origin: string | null): Record<string, string> {
|
|
151
|
+
return {
|
|
152
|
+
"access-control-allow-origin": origin ?? "*",
|
|
153
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
154
|
+
"access-control-allow-headers": "content-type",
|
|
155
|
+
"access-control-max-age": "86400",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Orchestration helper an adapter can call once it has parsed the
|
|
161
|
+
* envelope: read current state, reduce, write back. Adapters provide
|
|
162
|
+
* the platform-specific state access.
|
|
163
|
+
*/
|
|
164
|
+
export async function applyAnnotationEvent(
|
|
165
|
+
io: {
|
|
166
|
+
getState: () => AnnotationsState | Promise<AnnotationsState>;
|
|
167
|
+
setState: (next: AnnotationsState) => void | Promise<void>;
|
|
168
|
+
},
|
|
169
|
+
envelope: AgentationEvent,
|
|
170
|
+
): Promise<AnnotationsState> {
|
|
171
|
+
const current = await io.getState();
|
|
172
|
+
const next = annotationQueueReducer(current, envelope);
|
|
173
|
+
await io.setState(next);
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-store tests. Pure/in-memory — validates the audit-trail contract
|
|
3
|
+
* the AWS (DynamoDB + S3) adapter must satisfy.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
InMemoryRunStore,
|
|
10
|
+
makeRunRecord,
|
|
11
|
+
recordAnnotationEventRun,
|
|
12
|
+
} from "./run-store.js";
|
|
13
|
+
|
|
14
|
+
describe("InMemoryRunStore", () => {
|
|
15
|
+
it("puts and lists runs newest-first, filtered by kind/session", async () => {
|
|
16
|
+
const s = new InMemoryRunStore();
|
|
17
|
+
await s.putRun(makeRunRecord({ id: "1", kind: "test-run", at: "2026-05-29T00:00:01Z", status: "pass" }));
|
|
18
|
+
await s.putRun(makeRunRecord({ id: "2", kind: "annotation-event", at: "2026-05-29T00:00:02Z", sessionId: "cf" }));
|
|
19
|
+
await s.putRun(makeRunRecord({ id: "3", kind: "test-run", at: "2026-05-29T00:00:03Z", status: "fail" }));
|
|
20
|
+
|
|
21
|
+
const tests = await s.listRuns({ kind: "test-run" });
|
|
22
|
+
expect(tests.map((r) => r.id)).toEqual(["3", "1"]); // newest first
|
|
23
|
+
const byList = await s.listRuns({ sessionId: "cf" });
|
|
24
|
+
expect(byList).toHaveLength(1);
|
|
25
|
+
expect((await s.listRuns({ limit: 1 }))).toHaveLength(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is idempotent on id", async () => {
|
|
29
|
+
const s = new InMemoryRunStore();
|
|
30
|
+
await s.putRun(makeRunRecord({ id: "x", kind: "test-run", at: "t", status: "pass" }));
|
|
31
|
+
await s.putRun(makeRunRecord({ id: "x", kind: "test-run", at: "t", status: "fail" }));
|
|
32
|
+
const all = await s.listRuns();
|
|
33
|
+
expect(all).toHaveLength(1);
|
|
34
|
+
expect(all[0]!.status).toBe("fail");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stores artifacts and links them from records", async () => {
|
|
38
|
+
const s = new InMemoryRunStore();
|
|
39
|
+
await recordAnnotationEventRun(s, {
|
|
40
|
+
type: "annotation.created",
|
|
41
|
+
sessionId: "cf",
|
|
42
|
+
sequence: 7,
|
|
43
|
+
timestamp: "2026-05-29T00:00:00Z",
|
|
44
|
+
payload: { id: "ann_1" },
|
|
45
|
+
});
|
|
46
|
+
const [rec] = await s.listRuns({ kind: "annotation-event" });
|
|
47
|
+
expect(rec!.id).toBe("cf:7");
|
|
48
|
+
expect(rec!.artifactKey).toBe("annotations/cf/7.json");
|
|
49
|
+
expect(s.readArtifact(rec!.artifactKey!)).toContain("ann_1");
|
|
50
|
+
});
|
|
51
|
+
});
|