From d3e11f0c9abbe420a66be89bd1e0ac3ddfbbef14 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Fri, 17 Apr 2026 05:41:31 -0500 Subject: [PATCH] feat(daily-brief): deviation ingestion SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reusable SDK for posting skill deviations to OrgX when a skill fires locally (pre-PR, pre-commit, pre-chat) against a file edit, commit, PR, chat turn, or task output. - src/deviations-sdk.ts - computeDedupeKey(): sha1(skill_id | evidence_kind | evidence_ref | floor(epoch/600)) — matches server's UNIQUE (workspace_id, dedupe_key) constraint. 10-minute bucketing handles save-happy editors. - postDeviation(): POST /api/v1/skills/{id}/deviations with Bearer auth, structured error handling (no throw on HTTP failure), optional AbortController timeout. - postDeviationBatch(): parallel fan-out. - src/deviations-sdk.test.ts — 7 tests covering dedupe stability across bucket boundaries, POST shape, dedup echo, 4xx handling, network failure. Sister SDK for other plugin runtimes can either port this file directly (Node/TypeScript) or reimplement the same 5-field sha1 dedupe key in their language of choice. Companion server contract: orgx/docs/api-contracts/daily-brief-schema.md §04 Initiative a3b9a125-4078-4f24-9e10-088c6c6dd005. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/deviations-sdk.test.ts | 168 +++++++++++++++++++++++++++++++++ src/deviations-sdk.ts | 189 +++++++++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 src/deviations-sdk.test.ts create mode 100644 src/deviations-sdk.ts diff --git a/src/deviations-sdk.test.ts b/src/deviations-sdk.test.ts new file mode 100644 index 0000000..391468e --- /dev/null +++ b/src/deviations-sdk.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + computeDedupeKey, + postDeviation, + type PostDeviationInput, +} from "./deviations-sdk.js"; + +describe("computeDedupeKey", () => { + test("stable within a 10-minute bucket", () => { + const skillId = "parameterize-tests"; + const t0 = new Date("2026-04-17T10:00:00Z"); + const t5 = new Date("2026-04-17T10:05:00Z"); + const t9 = new Date("2026-04-17T10:09:59Z"); + + const k0 = computeDedupeKey({ + skillId, + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + capturedAt: t0, + }); + const k5 = computeDedupeKey({ + skillId, + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + capturedAt: t5, + }); + const k9 = computeDedupeKey({ + skillId, + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + capturedAt: t9, + }); + + assert.equal(k0, k5); + assert.equal(k5, k9); + }); + + test("changes across bucket boundary", () => { + const skillId = "parameterize-tests"; + const t0 = new Date("2026-04-17T10:09:30Z"); + const t1 = new Date("2026-04-17T10:11:30Z"); + + const k0 = computeDedupeKey({ + skillId, + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + capturedAt: t0, + }); + const k1 = computeDedupeKey({ + skillId, + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + capturedAt: t1, + }); + + assert.notEqual(k0, k1); + }); + + test("different ref → different key", () => { + const common = { + skillId: "s", + evidenceKind: "file_edit" as const, + capturedAt: new Date("2026-04-17T10:00:00Z"), + }; + assert.notEqual( + computeDedupeKey({ ...common, evidenceRef: "a" }), + computeDedupeKey({ ...common, evidenceRef: "b" }), + ); + }); +}); + +describe("postDeviation", () => { + const input: PostDeviationInput = { + skillId: "skill-1", + evidenceKind: "file_edit", + evidenceRef: "/src/foo.py:42", + summary: "class-based test refactored to parametrize", + applicationSource: "plugin_openclaw", + confidence: 0.91, + }; + + test("sends POST with bearer + expected body shape", async () => { + const captured: { url: string; init: RequestInit | undefined }[] = []; + const fetchMock: typeof fetch = async (url, init) => { + captured.push({ url: String(url), init }); + return new Response( + JSON.stringify({ id: "dev-123", deduplicated: false }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + const result = await postDeviation(input, { + apiBaseUrl: "https://useorgx.com", + apiKey: "oxk_test", + fetchImpl: fetchMock, + }); + + assert.equal(result.ok, true); + assert.equal(result.id, "dev-123"); + assert.equal(result.deduplicated, false); + assert.equal(captured.length, 1); + assert.equal( + captured[0]?.url, + "https://useorgx.com/api/v1/skills/skill-1/deviations", + ); + const headers = captured[0]?.init?.headers as Record; + assert.equal(headers?.Authorization, "Bearer oxk_test"); + const body = JSON.parse(String(captured[0]?.init?.body)); + assert.equal(body.evidence_kind, "file_edit"); + assert.equal(body.application_source, "plugin_openclaw"); + assert.equal(typeof body.dedupe_key, "string"); + assert.equal(body.dedupe_key.length, 40); // sha1 hex + }); + + test("reports deduplicated on server echo", async () => { + const fetchMock: typeof fetch = async () => + new Response(JSON.stringify({ id: "dev-prior", deduplicated: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const result = await postDeviation(input, { + apiBaseUrl: "https://useorgx.com", + apiKey: "oxk_test", + fetchImpl: fetchMock, + }); + + assert.equal(result.ok, true); + assert.equal(result.deduplicated, true); + assert.equal(result.id, "dev-prior"); + }); + + test("handles 4xx errors without throwing", async () => { + const fetchMock: typeof fetch = async () => + new Response(JSON.stringify({ error: "Invalid body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + + const result = await postDeviation(input, { + apiBaseUrl: "https://useorgx.com", + apiKey: "oxk_test", + fetchImpl: fetchMock, + }); + + assert.equal(result.ok, false); + assert.equal(result.status, 400); + assert.equal(result.error, "Invalid body"); + }); + + test("network failure produces structured error", async () => { + const fetchMock: typeof fetch = async () => { + throw new Error("ECONNREFUSED"); + }; + + const result = await postDeviation(input, { + apiBaseUrl: "https://useorgx.com", + apiKey: "oxk_test", + fetchImpl: fetchMock, + }); + + assert.equal(result.ok, false); + assert.equal(result.status, 0); + assert.equal(result.error, "ECONNREFUSED"); + }); +}); diff --git a/src/deviations-sdk.ts b/src/deviations-sdk.ts new file mode 100644 index 0000000..c99a3f2 --- /dev/null +++ b/src/deviations-sdk.ts @@ -0,0 +1,189 @@ +import { createHash } from "node:crypto"; + +/** + * Daily Brief — deviation ingestion SDK. + * + * Call when a skill fires locally (pre-PR, pre-commit, pre-chat) against + * a file edit, commit, PR, chat turn, or task output. The server enforces + * dedupe via UNIQUE (workspace_id, dedupe_key); plugin-side computation + * here matches the server's expectation: + * + * dedupe_key = sha1(skill_id | evidence_kind | evidence_ref | floor(epoch/600)) + * + * 10-minute bucketing handles save-happy editors without losing legitimate + * re-fires beyond that window. + * + * Endpoint contract: see orgx/docs/api-contracts/daily-brief-schema.md §04. + */ + +export type EvidenceKind = + | "pr" + | "commit" + | "file_edit" + | "chat_turn" + | "task_output"; + +export type ApplicationSource = + | "agent_run" + | "plugin_cursor" + | "plugin_claude" + | "plugin_codex" + | "plugin_openclaw" + | "manual"; + +export type DeviationOutcome = + | "pending" + | "confirmed" + | "rejected" + | "ignored"; + +export interface PostDeviationInput { + skillId: string; + evidenceKind: EvidenceKind; + /** e.g. "finance-dashboard#142" or "/path/to/file.py:42" */ + evidenceRef: string; + summary: string; + applicationSource: ApplicationSource; + /** 0..1 — plugin-measured match confidence */ + confidence: number; + outcome?: DeviationOutcome; + triggerContext?: Record; + capturedAt?: Date; + taskId?: string; + runId?: string; +} + +export interface PostDeviationOptions { + apiBaseUrl: string; + apiKey: string; + fetchImpl?: typeof fetch; + abortAfterMs?: number; +} + +export interface PostDeviationResult { + ok: boolean; + id: string | null; + deduplicated: boolean; + status: number; + error?: string; +} + +export function computeDedupeKey(input: { + skillId: string; + evidenceKind: EvidenceKind; + evidenceRef: string; + capturedAt?: Date; +}): string { + const now = input.capturedAt ?? new Date(); + const bucket = Math.floor(now.getTime() / 1000 / 600); + const material = [ + input.skillId, + input.evidenceKind, + input.evidenceRef, + String(bucket), + ].join("|"); + return createHash("sha1").update(material).digest("hex"); +} + +/** + * Post a deviation to OrgX. Returns the result shape from the server. + * + * On HTTP 409 / duplicate-key the server treats it as deduplicated — callers + * usually don't need to distinguish (the outcome is the same). + */ +export async function postDeviation( + input: PostDeviationInput, + options: PostDeviationOptions, +): Promise { + const capturedAt = input.capturedAt ?? new Date(); + const dedupeKey = computeDedupeKey({ + skillId: input.skillId, + evidenceKind: input.evidenceKind, + evidenceRef: input.evidenceRef, + capturedAt, + }); + + const url = buildUrl(options.apiBaseUrl, input.skillId); + const body = JSON.stringify({ + evidence_kind: input.evidenceKind, + evidence_ref: input.evidenceRef, + summary: input.summary, + application_source: input.applicationSource, + confidence: input.confidence, + outcome: input.outcome ?? "pending", + trigger_context: input.triggerContext ?? {}, + dedupe_key: dedupeKey, + captured_at: capturedAt.toISOString(), + ...(input.taskId ? { task_id: input.taskId } : {}), + ...(input.runId ? { run_id: input.runId } : {}), + }); + + const controller = new AbortController(); + const timeoutId = options.abortAfterMs + ? setTimeout(() => controller.abort(), options.abortAfterMs) + : null; + + try { + const fetchFn = options.fetchImpl ?? fetch; + const response = await fetchFn(url, { + method: "POST", + headers: { + Authorization: `Bearer ${options.apiKey}`, + "Content-Type": "application/json", + }, + body, + signal: controller.signal, + }); + + const payload = await response + .json() + .catch(() => null) as { + id?: string | null; + deduplicated?: boolean; + error?: string; + } | null; + + if (!response.ok) { + return { + ok: false, + id: payload?.id ?? null, + deduplicated: false, + status: response.status, + error: payload?.error ?? `HTTP ${response.status}`, + }; + } + + return { + ok: true, + id: payload?.id ?? null, + deduplicated: Boolean(payload?.deduplicated), + status: response.status, + }; + } catch (error) { + return { + ok: false, + id: null, + deduplicated: false, + status: 0, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +function buildUrl(apiBaseUrl: string, skillId: string): string { + const base = apiBaseUrl.replace(/\/+$/, ""); + return `${base}/api/v1/skills/${encodeURIComponent(skillId)}/deviations`; +} + +/** + * Convenience: post multiple deviations in parallel with a shared auth. + * Returns per-input results so callers can log which fired + which deduped. + */ +export async function postDeviationBatch( + inputs: PostDeviationInput[], + options: PostDeviationOptions, +): Promise { + return Promise.all(inputs.map((input) => postDeviation(input, options))); +}