Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/deviations-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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");
});
});
189 changes: 189 additions & 0 deletions src/deviations-sdk.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<PostDeviationResult> {
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<PostDeviationResult[]> {
return Promise.all(inputs.map((input) => postDeviation(input, options)));
}
Loading