mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
96fdd70d2c
* feat(api)!: redesign task resource operations
Publish the complete resource-oriented OpenAPI contract, enforce scope-based authorization at the HTTP boundary, and replace lifecycle command routes with Task PATCH and nested Claim/Event resources.
Add server-side Task CAS, migration coverage, updated Agent Skills, and browser/API regression tests.
BREAKING CHANGE: remove the legacy task lifecycle endpoints and move bounded waits to /tasks/{taskId}/events.
* refactor(tasks)!: make claims create-only
Remove Task Claim retrieval and deletion routes, the release scope, live deletion logic, and release workflow guidance.
Keep historical release records readable for migration and audit compatibility.
BREAKING CHANGE: remove GET and DELETE /tasks/{taskId}/claims/{claimId} and the task:release scope.
* test(ci): align actor and projection contracts
Cover machine and service Task Action writes and update Agent detail fixtures to the canonical assignedTo query and paged lowerCamelCase response.
* fix(api): preserve merge patch fields and consume browser pagination
* test(ui): include terminal pagination in agent task fixture
---------
Co-authored-by: jarvis <jarvis@agents.realmroot.dev>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
import {
|
|
deleteExpiredIdempotencyRecords,
|
|
type IdempotentHttpResponse,
|
|
type ResourceIdempotency,
|
|
ResourceIdempotencyConflict,
|
|
ResourceIdempotencyReplay,
|
|
resolveIdempotentResponse,
|
|
} from "@server/adapters/d1/resourceIdempotency";
|
|
import type { Env } from "@server/env";
|
|
import { apiErrorHandler } from "@server/http/middleware/errorHandler";
|
|
import { effectiveApiVersion, v2Problem } from "@server/http/middleware/v2Contract";
|
|
import type { Context, ErrorHandler, Next } from "hono";
|
|
|
|
export async function idempotencyMiddleware(c: Context<{ Bindings: Env }>, next: Next): Promise<Response | undefined> {
|
|
const principal = c.get("principal");
|
|
const resourceKind = creationResourceKind(c.req.method, c.req.path);
|
|
if (!resourceKind) {
|
|
await next();
|
|
return undefined;
|
|
}
|
|
|
|
const fieldValue = c.req.header("idempotency-key");
|
|
if (!fieldValue) {
|
|
return v2Problem(c, 400, "idempotency-key-required", "Idempotency key required", "Idempotency-Key is required for this creation");
|
|
}
|
|
const key = parseIdempotencyKey(fieldValue);
|
|
if (!key) {
|
|
return v2Problem(
|
|
c,
|
|
400,
|
|
"invalid-idempotency-key",
|
|
"Invalid idempotency key",
|
|
'Idempotency-Key must be an RFC 8941 quoted string containing 8 to 200 safe characters, for example "request-uuid"',
|
|
);
|
|
}
|
|
|
|
const actorId = principal.actorId ?? principal.subjectId;
|
|
const ownerId = c.get("ownerId");
|
|
const apiVersion = effectiveApiVersion(c);
|
|
const requestHash = await hashRequest(
|
|
actorId,
|
|
apiVersion,
|
|
c.req.method,
|
|
c.req.path,
|
|
c.req.header("content-type") ?? "",
|
|
await c.req.raw.clone().arrayBuffer(),
|
|
);
|
|
const idempotency: ResourceIdempotency = {
|
|
ownerId,
|
|
actorId,
|
|
apiVersion,
|
|
key,
|
|
method: c.req.method,
|
|
path: c.req.path,
|
|
requestHash,
|
|
upstreamKey: await hashUpstreamKey(ownerId, actorId, apiVersion, c.req.method, c.req.path, key),
|
|
resourceKind,
|
|
};
|
|
|
|
await deleteExpiredIdempotencyRecords(c.env.DB);
|
|
try {
|
|
const replay = await resolveIdempotentResponse(c.env.DB, idempotency);
|
|
if (replay) return replayResponse(replay);
|
|
} catch (error) {
|
|
if (error instanceof ResourceIdempotencyConflict) {
|
|
return v2Problem(c, 422, "idempotency-key-conflict", "Idempotency key conflict", error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
c.set("resourceIdempotency", idempotency);
|
|
await next();
|
|
return undefined;
|
|
}
|
|
|
|
export const resourceServerErrorHandler: ErrorHandler = (error, c) => {
|
|
if (error instanceof ResourceIdempotencyReplay) return replayResponse(error.response);
|
|
if (error instanceof ResourceIdempotencyConflict) {
|
|
return v2Problem(c, 422, "idempotency-key-conflict", "Idempotency key conflict", error.message);
|
|
}
|
|
return apiErrorHandler(error, c);
|
|
};
|
|
|
|
async function hashUpstreamKey(ownerId: string, actorId: string, apiVersion: string, method: string, path: string, key: string): Promise<string> {
|
|
const input = new TextEncoder().encode(`${ownerId}\n${actorId}\n${apiVersion}\n${method}\n${path}\n${key}`);
|
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
|
|
return `ak-${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
}
|
|
|
|
export function replayResponse(response: IdempotentHttpResponse): Response {
|
|
const resourceHeaders = {
|
|
...(response.location ? { Location: response.location } : {}),
|
|
...(response.etag ? { ETag: response.etag } : {}),
|
|
};
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers: {
|
|
"Content-Type": response.contentType,
|
|
...resourceHeaders,
|
|
"Idempotency-Replayed": "true",
|
|
},
|
|
});
|
|
}
|
|
|
|
function creationResourceKind(method: string, path: string): string | null {
|
|
if (method !== "POST") return null;
|
|
if (path === "/api/tasks") return "task";
|
|
if (path === "/api/agents") return "agent";
|
|
if (path === "/api/machines") return "machine";
|
|
if (/^\/api\/tasks\/[^/]+\/notes$/.test(path)) return "task-note";
|
|
if (/^\/api\/tasks\/[^/]+\/claims$/.test(path)) return "task-claim";
|
|
return null;
|
|
}
|
|
|
|
function parseIdempotencyKey(fieldValue: string): string | null {
|
|
const quoted = /^"([A-Za-z0-9._:-]{8,200})"$/.exec(fieldValue);
|
|
if (quoted) return quoted[1];
|
|
return null;
|
|
}
|
|
|
|
async function hashRequest(
|
|
actorId: string,
|
|
apiVersion: string,
|
|
method: string,
|
|
path: string,
|
|
contentType: string,
|
|
bodyBuffer: ArrayBuffer,
|
|
): Promise<string> {
|
|
const body = new Uint8Array(bodyBuffer);
|
|
const prefix = new TextEncoder().encode(`${actorId}\n${apiVersion}\n${method}\n${path}\n${contentType}\n`);
|
|
const bytes = new Uint8Array(prefix.length + body.length);
|
|
bytes.set(prefix);
|
|
bytes.set(body, prefix.length);
|
|
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|