源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# `@openwork/automations`
Pure, infrastructure-free Automations domain shared by hosted and on-prem Den.
It owns the contracts, deterministic once/daily/weekly schedule calculation,
DST behavior, lifecycle transitions, revision digests, occurrence identity,
idempotency, repository and engine-adapter ports, due-work selection, bounded missed
recovery, and repository conformance helpers.
It deliberately has no runtime adapter. Den supplies MySQL persistence, leases,
membership and model checks, Connect access, and the model execution adapter.
The OpenWork desktop is only a Den client and is never an Automation scheduler
or execution host.
## Hosted engine lifecycle
`AutomationEngineAdapter` is the provider-neutral boundary for hosted runs.
Den creates and persists an admission key before calling `admit`; retrying that
key must return the same persistence-safe receipt. The capability access token
belongs only to the admission request and must never be copied into the receipt.
Den persists each observed event by its stable idempotency key before advancing
the contiguous sequence cursor. After a process restart, it loads the receipt
and cursor, calls `read` for the durable state/result, and resumes `observe`
without an in-memory engine handle. Cancellation uses the same receipt, so it
also survives scheduler ownership changes and Den restarts.
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@openwork/automations",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"bun": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
},
"./testing": {
"types": "./src/testing.ts",
"bun": "./src/testing.ts",
"development": "./src/testing.ts",
"default": "./dist/testing.js"
}
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test src"
},
"dependencies": {
"@openwork/types": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"bun-types": "^1.3.6",
"tsup": "^8.5.0",
"typescript": "^5.6.3"
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { AutomationRevision } from "@openwork/types/automations"
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(",")}}`
}
return JSON.stringify(value)
}
/**
* Portable stable digest; persistence may additionally use a cryptographic digest.
*
* Callers must include `workspaceId` only when it is set: a pinned workspace is
* behavior-changing, but records created before pinning existed must keep their
* digests byte-identical.
*/
export function automationRevisionDigest(
revision: Pick<AutomationRevision, "instructions" | "schedule" | "model" | "maximumRuntimeMs">
& Partial<Pick<AutomationRevision, "action" | "executionTarget" | "workspaceId">>,
): string {
const input = canonical(revision)
let left = 0x811c9dc5
let right = 0x9e3779b9
for (let index = 0; index < input.length; index += 1) {
const code = input.charCodeAt(index)
left = Math.imul(left ^ code, 0x01000193)
right = Math.imul(right ^ code, 0x85ebca6b)
}
return `${(left >>> 0).toString(16).padStart(8, "0")}${(right >>> 0).toString(16).padStart(8, "0")}`
}
export interface AutomationOccurrenceIdentityInput {
automationId: string
scheduledFor: number | null
nonce?: string
}
export function automationOccurrenceIdentity(input: AutomationOccurrenceIdentityInput): {
occurrenceId: string
idempotencyKey: string
} {
if (input.scheduledFor === null && !input.nonce) throw new Error("A manual occurrence needs a nonce")
const occurrence = input.scheduledFor === null ? `manual:${input.nonce}` : String(input.scheduledFor)
const stable = [input.automationId, occurrence]
.map(encodeURIComponent).join(":")
return {
occurrenceId: `automation-occurrence:${stable}`,
idempotencyKey: `automation:${stable}`,
}
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test"
import { automationScheduleSchema } from "@openwork/types/automations"
import { automationOccurrenceIdentity, automationRevisionDigest } from "./contracts.js"
import { nextAutomationOccurrence, recoverableAutomationOccurrence } from "./schedule.js"
import { assertAutomationTransition, canTransitionAutomation, isTerminalAutomationRunStatus } from "./state.js"
import { selectDueAutomations } from "./tick.js"
describe("portable Automations core", () => {
test("calculates once, daily, and weekly occurrences", () => {
expect(nextAutomationOccurrence({ kind: "once", timezone: "UTC", at: 10 }, 9)).toBe(10)
expect(nextAutomationOccurrence({ kind: "once", timezone: "UTC", at: 10 }, 10)).toBeNull()
expect(new Date(nextAutomationOccurrence({ kind: "daily", timezone: "UTC", hour: 9, minute: 30 }, Date.UTC(2026, 0, 1)) ?? 0).toISOString())
.toBe("2026-01-01T09:30:00.000Z")
expect(new Date(nextAutomationOccurrence({ kind: "weekly", timezone: "UTC", daysOfWeek: [1], hour: 9, minute: 30 }, Date.UTC(2026, 0, 1)) ?? 0).toISOString())
.toBe("2026-01-05T09:30:00.000Z")
})
test("keeps deterministic DST behavior without process timezone state", () => {
const fallBack = nextAutomationOccurrence(
{ kind: "daily", timezone: "America/New_York", hour: 1, minute: 30 },
Date.UTC(2026, 9, 31, 12),
)
expect(new Date(fallBack ?? 0).toISOString()).toBe("2026-11-01T05:30:00.000Z")
const springForward = nextAutomationOccurrence(
{ kind: "daily", timezone: "America/New_York", hour: 2, minute: 30 },
Date.UTC(2026, 2, 7, 12),
)
expect(new Date(springForward ?? 0).toISOString()).toBe("2026-03-08T07:00:00.000Z")
})
test("bounds missed recovery to one latest occurrence", () => {
const recovered = recoverableAutomationOccurrence(
{ kind: "daily", timezone: "UTC", hour: 9, minute: 0 },
{ after: Date.UTC(2026, 0, 1), now: Date.UTC(2026, 0, 5, 12) },
)
expect(recovered).toBe(Date.UTC(2026, 0, 5, 9))
})
test("normalizes weekly weekdays and rejects invalid timezones", () => {
const weekly = automationScheduleSchema.parse({
kind: "weekly", timezone: "Europe/Madrid", daysOfWeek: [5, 1, 5], hour: 8, minute: 0,
})
expect(weekly.kind === "weekly" ? weekly.daysOfWeek : []).toEqual([1, 5])
expect(automationScheduleSchema.safeParse({
kind: "daily", timezone: "Mars/Olympus", hour: 8, minute: 0,
}).success).toBe(false)
})
test("defines lifecycle and terminal states", () => {
expect(canTransitionAutomation("active", "inactive")).toBe(true)
expect(canTransitionAutomation("archived", "active")).toBe(false)
expect(() => assertAutomationTransition("archived", "active")).toThrow()
expect(isTerminalAutomationRunStatus("succeeded")).toBe(true)
expect(isTerminalAutomationRunStatus("running")).toBe(false)
})
test("creates stable revision and occurrence identities", () => {
const revision = {
instructions: "Say hello",
schedule: { kind: "daily" as const, timezone: "UTC", hour: 9, minute: 0 },
model: { providerId: "provider", modelId: "model" },
maximumRuntimeMs: 60_000,
}
expect(automationRevisionDigest(revision)).toBe(automationRevisionDigest({ ...revision }))
const scheduled = automationOccurrenceIdentity({
automationId: "automation_a", scheduledFor: 10,
})
expect(scheduled).toEqual({
occurrenceId: "automation-occurrence:automation_a:10",
idempotencyKey: "automation:automation_a:10",
})
})
test("deduplicates scheduled and recovery claims for one occurrence", () => {
const runs = new Map<string, { id: string; revisionId: string; trigger: "scheduled" | "recovery" }>()
const claim = (revisionId: string, trigger: "scheduled" | "recovery") => {
const identity = automationOccurrenceIdentity({ automationId: "automation_a", scheduledFor: 10 })
const existing = runs.get(identity.idempotencyKey)
if (existing) return existing
const run = { id: `run_${runs.size + 1}`, revisionId, trigger }
runs.set(identity.idempotencyKey, run)
return run
}
const scheduledRun = claim("revision_a", "scheduled")
const recoveredRunAfterRevisionChange = claim("revision_b", "recovery")
expect(recoveredRunAfterRevisionChange).toBe(scheduledRun)
expect(runs.size).toBe(1)
expect(scheduledRun).toEqual({ id: "run_1", revisionId: "revision_a", trigger: "scheduled" })
})
test("selects due active Automations deterministically", () => {
const revision = {
id: "revision_a", automationId: "automation_a", version: 1, instructions: "Run",
schedule: { kind: "daily" as const, timezone: "UTC", hour: 9, minute: 0 },
model: { providerId: "provider", modelId: "model" }, maximumRuntimeMs: 60_000,
digest: "0123456789abcdef", createdAt: 1,
}
const automation = {
id: "automation_a", organizationId: "org", ownerMemberId: "member", name: "A",
state: "active" as const, currentRevisionId: revision.id, nextDueAt: 10, latestRunAt: null,
needsAttentionReason: null, createdAt: 1, updatedAt: 1, archivedAt: null,
}
expect(selectDueAutomations([
{ automation: { ...automation, id: "automation_b", nextDueAt: 20 }, revision: { ...revision, id: "revision_b", automationId: "automation_b" }, latestRun: null },
{ automation, revision, latestRun: null },
], { now: 20, limit: 1 }).map((item) => item.automation.id)).toEqual(["automation_a"])
})
})
+474
View File
@@ -0,0 +1,474 @@
import {
automationRevisionSchema,
automationRunSchema,
automationSchema,
type AutomationError,
type AutomationRunEventType,
type AutomationUsage,
} from "@openwork/types/automations"
import {
automationEngineAdmissionReceiptSchema,
automationEngineAdmissionRequestSchema,
automationEngineCancellationResultSchema,
automationEngineCapabilityDeclarationSchema,
automationEngineEventSchema,
automationEnginePendingResultSchema,
automationEngineReadResultSchema,
automationEngineResultSchema,
createAutomationEngineEventSequenceValidator,
type AutomationEngineAdapter,
type AutomationEngineAdmissionReceipt,
type AutomationEngineAdmissionRequest,
type AutomationEngineCapabilityDeclaration,
type AutomationEngineEvent,
type AutomationEngineExecutionState,
type AutomationEngineResult,
} from "./engine.js"
type StoredExecution = {
request: AutomationEngineAdmissionRequest
receipt: AutomationEngineAdmissionReceipt
events: AutomationEngineEvent[]
state: AutomationEngineExecutionState
result: AutomationEngineResult | null
updatedAt: number
}
export interface FakeAutomationEngineHarness {
adapter: AutomationEngineAdapter
emit(input: {
receipt: AutomationEngineAdmissionReceipt
type: AutomationRunEventType
payload: Record<string, unknown>
createdAt: number
}): AutomationEngineEvent
complete(input: {
receipt: AutomationEngineAdmissionReceipt
status: "succeeded" | "failed" | "cancelled"
threadId: string | null
resultSummary: string | null
usage: AutomationUsage
error: AutomationError | null
finishedAt: number
}): AutomationEngineResult
}
function fakeCapabilities(input?: {
adapterId?: string
cancellation?: AutomationEngineCapabilityDeclaration["cancellation"]
}) {
return automationEngineCapabilityDeclarationSchema.parse({
adapterId: input?.adapterId ?? "fake-automation-engine",
protocolVersion: 1,
admission: "idempotent",
reattachment: "receipt",
eventDelivery: "ordered_at_least_once",
resultPersistence: "durable",
cancellation: input?.cancellation ?? "supported",
isolation: {
location: "cloud",
filesystem: "none",
shell: false,
browser: false,
computer: false,
connect: "run-scoped",
network: "provider-and-connect-only",
},
})
}
export function createFakeAutomationEngineAdapter(input?: {
adapterId?: string
cancellation?: AutomationEngineCapabilityDeclaration["cancellation"]
now?: () => number
}): FakeAutomationEngineHarness {
const capabilities = fakeCapabilities(input)
const now = input?.now ?? Date.now
const executions = new Map<string, StoredExecution>()
const admissions = new Map<string, string>()
let nextExecution = 1
function stored(receipt: AutomationEngineAdmissionReceipt): StoredExecution | null {
if (receipt.adapterId !== capabilities.adapterId) return null
const execution = executions.get(receipt.executionId)
if (!execution || execution.receipt.runId !== receipt.runId) return null
return execution
}
function appendEvent(
execution: StoredExecution,
input: {
type: AutomationRunEventType
payload: Record<string, unknown>
createdAt: number
},
): AutomationEngineEvent {
if (execution.result) throw new Error("Cannot append an event after a terminal result")
const sequence = execution.events.length + 1
const event = automationEngineEventSchema.parse({
id: `${execution.receipt.executionId}:event:${sequence}`,
idempotencyKey: `${execution.receipt.admissionKey}:event:${sequence}`,
executionId: execution.receipt.executionId,
runId: execution.receipt.runId,
sequence,
type: input.type,
payload: input.payload,
createdAt: input.createdAt,
})
execution.events.push(event)
execution.state = "running"
execution.updatedAt = input.createdAt
return event
}
function finish(
execution: StoredExecution,
input: {
status: "succeeded" | "failed" | "cancelled"
threadId: string | null
resultSummary: string | null
usage: AutomationUsage
error: AutomationError | null
finishedAt: number
},
): AutomationEngineResult {
if (execution.result) return execution.result
const result = automationEngineResultSchema.parse({
executionId: execution.receipt.executionId,
runId: execution.receipt.runId,
status: input.status,
threadId: input.threadId,
resultSummary: input.resultSummary,
usage: input.usage,
error: input.error,
finalSequence: execution.events.length,
finishedAt: input.finishedAt,
})
execution.result = result
execution.state = input.status
execution.updatedAt = input.finishedAt
return result
}
const adapter: AutomationEngineAdapter = {
capabilities() {
return Promise.resolve(automationEngineCapabilityDeclarationSchema.parse(capabilities))
},
admit(rawRequest) {
const request = automationEngineAdmissionRequestSchema.parse(rawRequest)
const existingId = admissions.get(request.admissionKey)
if (existingId) {
const existing = executions.get(existingId)
if (!existing) throw new Error("Fake Automation engine admission index is corrupt")
if (
existing.request.run.id !== request.run.id
|| existing.request.automation.id !== request.automation.id
|| existing.request.revision.id !== request.revision.id
) {
throw new Error("Automation engine admission key was reused for different work")
}
return Promise.resolve(automationEngineAdmissionReceiptSchema.parse(existing.receipt))
}
const executionId = `${capabilities.adapterId}:execution:${nextExecution}`
nextExecution += 1
const receipt = automationEngineAdmissionReceiptSchema.parse({
receiptVersion: 1,
adapterId: capabilities.adapterId,
executionId,
admissionKey: request.admissionKey,
runId: request.run.id,
admittedAt: request.requestedAt,
attachment: { handle: executionId },
})
executions.set(executionId, {
request,
receipt,
events: [],
state: "admitted",
result: null,
updatedAt: request.requestedAt,
})
admissions.set(request.admissionKey, executionId)
return Promise.resolve(receipt)
},
observe(rawReceipt, options) {
const receipt = automationEngineAdmissionReceiptSchema.parse(rawReceipt)
const execution = stored(receipt)
const afterSequence = options?.afterSequence ?? 0
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
throw new Error("Automation engine event cursor must be a non-negative integer")
}
const events = execution?.events
.filter((event) => event.sequence > afterSequence) ?? []
const signal = options?.signal
return (async function* observeEvents() {
for (const event of events) {
if (signal?.aborted) return
yield automationEngineEventSchema.parse(event)
}
})()
},
read(rawReceipt) {
const receipt = automationEngineAdmissionReceiptSchema.parse(rawReceipt)
const execution = stored(receipt)
if (!execution) return Promise.resolve(null)
if (execution.result) {
return Promise.resolve(automationEngineReadResultSchema.parse(execution.result))
}
return Promise.resolve(automationEnginePendingResultSchema.parse({
status: "pending",
state: execution.state,
executionId: execution.receipt.executionId,
runId: execution.receipt.runId,
latestSequence: execution.events.length,
updatedAt: execution.updatedAt,
}))
},
cancel(rawReceipt) {
const receipt = automationEngineAdmissionReceiptSchema.parse(rawReceipt)
const requestedAt = now()
const execution = stored(receipt)
if (!execution) {
return Promise.resolve(automationEngineCancellationResultSchema.parse({
executionId: receipt.executionId,
runId: receipt.runId,
outcome: "not_found",
requestedAt,
}))
}
if (execution.result) {
return Promise.resolve(automationEngineCancellationResultSchema.parse({
executionId: receipt.executionId,
runId: receipt.runId,
outcome: "already_terminal",
requestedAt,
}))
}
if (capabilities.cancellation === "unsupported") {
return Promise.resolve(automationEngineCancellationResultSchema.parse({
executionId: receipt.executionId,
runId: receipt.runId,
outcome: "unsupported",
requestedAt,
}))
}
appendEvent(execution, {
type: "terminal",
payload: { status: "cancelled" },
createdAt: requestedAt,
})
finish(execution, {
status: "cancelled",
threadId: null,
resultSummary: null,
usage: { inputTokens: null, outputTokens: null, costMicros: null },
error: {
code: "cancelled",
message: "The Automation engine execution was cancelled.",
retryable: false,
},
finishedAt: requestedAt,
})
return Promise.resolve(automationEngineCancellationResultSchema.parse({
executionId: receipt.executionId,
runId: receipt.runId,
outcome: "requested",
requestedAt,
}))
},
}
return {
adapter,
emit(eventInput) {
const execution = stored(eventInput.receipt)
if (!execution) throw new Error("Unknown fake Automation engine receipt")
return appendEvent(execution, eventInput)
},
complete(resultInput) {
const execution = stored(resultInput.receipt)
if (!execution) throw new Error("Unknown fake Automation engine receipt")
return finish(execution, resultInput)
},
}
}
function conformanceAdmission(input?: {
admissionKey?: string
runId?: string
}): AutomationEngineAdmissionRequest {
const automation = automationSchema.parse({
id: "automation_engine_conformance",
organizationId: "organization_engine_conformance",
ownerMemberId: "member_engine_conformance",
name: "Engine conformance Automation",
state: "active",
currentRevisionId: "revision_engine_conformance",
nextDueAt: 20_000,
latestRunAt: null,
needsAttentionReason: null,
createdAt: 1_000,
updatedAt: 1_000,
archivedAt: null,
})
const revision = automationRevisionSchema.parse({
id: automation.currentRevisionId,
automationId: automation.id,
version: 1,
instructions: "Return the word ready.",
schedule: { kind: "daily", timezone: "UTC", hour: 9, minute: 0 },
model: { providerId: "provider_engine_conformance", modelId: "model_engine_conformance" },
executionTarget: "desktop",
maximumRuntimeMs: 60_000,
digest: "0123456789abcdef",
createdAt: 1_000,
})
const run = automationRunSchema.parse({
id: input?.runId ?? "run_engine_conformance",
automationId: automation.id,
revisionId: revision.id,
trigger: "scheduled",
scheduledFor: 20_000,
idempotencyKey: "automation:engine-conformance",
status: "claimed",
leaseOwner: "den_conformance",
leaseExpiresAt: 60_000,
heartbeatAt: 20_000,
attemptCount: 1,
executionTarget: "desktop",
executionThread: null,
providerId: revision.model.providerId,
modelId: revision.model.modelId,
startedAt: null,
finishedAt: null,
error: null,
resultSummary: null,
usage: { inputTokens: null, outputTokens: null, costMicros: null },
createdAt: 20_000,
updatedAt: 20_000,
})
return automationEngineAdmissionRequestSchema.parse({
admissionKey: input?.admissionKey ?? "admission_engine_conformance",
automation,
revision,
run,
capabilityAccess: {
endpoint: `https://den.example.test/mcp/automation-runs/${run.id}`,
bearerToken: "ephemeral-engine-conformance-token",
expiresAt: 120_000,
},
requestedAt: 20_000,
})
}
export async function verifyFakeAutomationEngineAdapterConformance(
harness: FakeAutomationEngineHarness = createFakeAutomationEngineAdapter(),
): Promise<string[]> {
const checked: string[] = []
const capabilities = automationEngineCapabilityDeclarationSchema.parse(
await harness.adapter.capabilities(),
)
if (capabilities.reattachment !== "receipt") {
throw new Error("Automation engine must support receipt reattachment")
}
checked.push("capability declaration")
const request = conformanceAdmission()
const firstReceipt = automationEngineAdmissionReceiptSchema.parse(
await harness.adapter.admit(request),
)
const duplicateReceipt = automationEngineAdmissionReceiptSchema.parse(
await harness.adapter.admit(request),
)
if (JSON.stringify(firstReceipt) !== JSON.stringify(duplicateReceipt)) {
throw new Error("Automation engine admission was not idempotent")
}
checked.push("idempotent admission")
harness.emit({
receipt: firstReceipt,
type: "assistant",
payload: { text: "ready" },
createdAt: 21_000,
})
harness.emit({
receipt: firstReceipt,
type: "usage",
payload: { inputTokens: 1, outputTokens: 1 },
createdAt: 22_000,
})
const firstEvents: AutomationEngineEvent[] = []
const firstValidator = createAutomationEngineEventSequenceValidator(firstReceipt)
for await (const event of harness.adapter.observe(firstReceipt)) {
firstValidator.accept(event)
firstEvents.push(event)
}
const replayedEvents: AutomationEngineEvent[] = []
for await (const event of harness.adapter.observe(firstReceipt)) {
replayedEvents.push(event)
}
if (JSON.stringify(firstEvents) !== JSON.stringify(replayedEvents)) {
throw new Error("Automation engine replay changed durable event identity")
}
const persistedReceipt = automationEngineAdmissionReceiptSchema.parse(
JSON.parse(JSON.stringify(firstReceipt)),
)
const persistedCursor = firstEvents[0]?.sequence ?? 0
const resumedEvents: AutomationEngineEvent[] = []
const resumedValidator = createAutomationEngineEventSequenceValidator(
persistedReceipt,
persistedCursor,
)
for await (const event of harness.adapter.observe(persistedReceipt, {
afterSequence: persistedCursor,
})) {
resumedValidator.accept(event)
resumedEvents.push(event)
}
if (resumedEvents[0]?.sequence !== 2 || resumedValidator.cursor !== 2) {
throw new Error("Automation engine did not resume from the persisted event cursor")
}
checked.push("ordered idempotent events")
harness.complete({
receipt: firstReceipt,
status: "succeeded",
threadId: "thread_engine_conformance",
resultSummary: "ready",
usage: { inputTokens: 1, outputTokens: 1, costMicros: null },
error: null,
finishedAt: 23_000,
})
const firstRead = await harness.adapter.read(persistedReceipt)
const secondRead = await harness.adapter.read(persistedReceipt)
if (
firstRead?.status !== "succeeded"
|| JSON.stringify(firstRead) !== JSON.stringify(secondRead)
) {
throw new Error("Automation engine terminal result was not durable across reattachment")
}
checked.push("restart reattachment")
checked.push("durable terminal result")
const cancelReceipt = await harness.adapter.admit(conformanceAdmission({
admissionKey: "admission_engine_cancellation",
runId: "run_engine_cancellation",
}))
const cancellation = await harness.adapter.cancel(cancelReceipt)
const cancelledResult = await harness.adapter.read(cancelReceipt)
if (
capabilities.cancellation === "unsupported"
? cancellation.outcome !== "unsupported"
|| cancelledResult?.status !== "pending"
|| cancelledResult.state !== "admitted"
: cancellation.outcome !== "requested" || cancelledResult?.status !== "cancelled"
) {
throw new Error("Automation engine did not honor its cancellation declaration")
}
checked.push("declared cancellation")
return checked
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import {
createFakeAutomationEngineAdapter,
verifyFakeAutomationEngineAdapterConformance,
} from "./engine-testing.js"
describe("provider-neutral Automation engine adapter", () => {
test("supports durable admission, observation, reattachment, and cancellation", async () => {
expect(await verifyFakeAutomationEngineAdapterConformance()).toEqual([
"capability declaration",
"idempotent admission",
"ordered idempotent events",
"restart reattachment",
"durable terminal result",
"declared cancellation",
])
})
test("reports unsupported cancellation without terminalizing the execution", async () => {
const harness = createFakeAutomationEngineAdapter({ cancellation: "unsupported" })
const checked = await verifyFakeAutomationEngineAdapterConformance(harness)
expect(checked).toContain("declared cancellation")
})
})
+240
View File
@@ -0,0 +1,240 @@
import { z } from "zod"
import {
automationErrorSchema,
automationRevisionSchema,
automationRunEventTypeSchema,
automationRunSchema,
automationSchema,
automationUsageSchema,
} from "@openwork/types/automations"
const engineIdSchema = z.string().trim().min(1).max(240)
const timestampSchema = z.number().int().nonnegative()
export type AutomationEngineAttachmentValue =
| string
| number
| boolean
| null
| AutomationEngineAttachmentValue[]
| { [key: string]: AutomationEngineAttachmentValue }
export const automationEngineAttachmentValueSchema: z.ZodType<
AutomationEngineAttachmentValue
> = z.lazy(() => z.union([
z.string(),
z.number().finite(),
z.boolean(),
z.null(),
z.array(automationEngineAttachmentValueSchema),
z.record(z.string(), automationEngineAttachmentValueSchema),
]))
/**
* Provider-neutral behavior Den can rely on before admitting work. Receipts,
* events, and results remain readable after the Den process that admitted the
* run has stopped.
*/
export const automationEngineCapabilityDeclarationSchema = z.object({
adapterId: engineIdSchema,
protocolVersion: z.literal(1),
admission: z.literal("idempotent"),
reattachment: z.literal("receipt"),
eventDelivery: z.literal("ordered_at_least_once"),
resultPersistence: z.literal("durable"),
cancellation: z.enum(["supported", "best_effort", "unsupported"]),
isolation: z.object({
location: z.literal("cloud"),
filesystem: z.literal("none"),
shell: z.literal(false),
browser: z.literal(false),
computer: z.literal(false),
connect: z.literal("run-scoped"),
network: z.literal("provider-and-connect-only"),
}),
})
export type AutomationEngineCapabilityDeclaration = z.infer<
typeof automationEngineCapabilityDeclarationSchema
>
export const automationEngineCapabilityAccessSchema = z.object({
endpoint: z.string().url(),
bearerToken: z.string().min(1).max(16_384),
expiresAt: timestampSchema,
})
export type AutomationEngineCapabilityAccess = z.infer<
typeof automationEngineCapabilityAccessSchema
>
export const automationEngineAdmissionRequestSchema = z.object({
admissionKey: z.string().trim().min(1).max(512),
automation: automationSchema,
revision: automationRevisionSchema,
run: automationRunSchema,
capabilityAccess: automationEngineCapabilityAccessSchema,
requestedAt: timestampSchema,
}).superRefine((request, context) => {
if (request.revision.automationId !== request.automation.id) {
context.addIssue({ code: "custom", message: "Revision does not belong to the Automation" })
}
if (
request.run.automationId !== request.automation.id
|| request.run.revisionId !== request.revision.id
) {
context.addIssue({ code: "custom", message: "Run does not belong to the Automation revision" })
}
})
export type AutomationEngineAdmissionRequest = z.infer<
typeof automationEngineAdmissionRequestSchema
>
/**
* Persistence-safe reattachment receipt. `attachment` is opaque to Den and
* must not contain credentials; the adapter owns its shape and interpretation.
*/
export const automationEngineAdmissionReceiptSchema = z.object({
receiptVersion: z.literal(1),
adapterId: engineIdSchema,
executionId: engineIdSchema,
admissionKey: z.string().trim().min(1).max(512),
runId: engineIdSchema,
admittedAt: timestampSchema,
attachment: z.record(z.string(), automationEngineAttachmentValueSchema),
})
export type AutomationEngineAdmissionReceipt = z.infer<
typeof automationEngineAdmissionReceiptSchema
>
export const automationEngineExecutionStateSchema = z.enum([
"admitted",
"running",
"succeeded",
"failed",
"cancelled",
])
export type AutomationEngineExecutionState = z.infer<
typeof automationEngineExecutionStateSchema
>
/** Stable event identity plus a strictly increasing per-execution sequence. */
export const automationEngineEventSchema = z.object({
id: engineIdSchema,
idempotencyKey: z.string().trim().min(1).max(512),
executionId: engineIdSchema,
runId: engineIdSchema,
sequence: z.number().int().positive(),
type: automationRunEventTypeSchema,
payload: z.record(z.string(), automationEngineAttachmentValueSchema),
createdAt: timestampSchema,
})
export type AutomationEngineEvent = z.infer<typeof automationEngineEventSchema>
export const automationEngineResultSchema = z.object({
executionId: engineIdSchema,
runId: engineIdSchema,
status: z.enum(["succeeded", "failed", "cancelled"]),
threadId: engineIdSchema.nullable(),
resultSummary: z.string().max(20_000).nullable(),
usage: automationUsageSchema,
error: automationErrorSchema.nullable(),
finalSequence: z.number().int().nonnegative(),
finishedAt: timestampSchema,
}).superRefine((result, context) => {
if (result.status === "succeeded" && result.error !== null) {
context.addIssue({ code: "custom", message: "A succeeded engine result cannot contain an error" })
}
if (result.status !== "succeeded" && result.error === null) {
context.addIssue({ code: "custom", message: "A failed or cancelled engine result needs an error" })
}
})
export type AutomationEngineResult = z.infer<typeof automationEngineResultSchema>
export const automationEnginePendingResultSchema = z.object({
status: z.literal("pending"),
state: z.enum(["admitted", "running"]),
executionId: engineIdSchema,
runId: engineIdSchema,
latestSequence: z.number().int().nonnegative(),
updatedAt: timestampSchema,
})
export type AutomationEnginePendingResult = z.infer<
typeof automationEnginePendingResultSchema
>
export const automationEngineReadResultSchema = z.union([
automationEnginePendingResultSchema,
automationEngineResultSchema,
])
export type AutomationEngineReadResult = z.infer<
typeof automationEngineReadResultSchema
>
export const automationEngineCancellationResultSchema = z.object({
executionId: engineIdSchema,
runId: engineIdSchema,
outcome: z.enum(["requested", "already_terminal", "unsupported", "not_found"]),
requestedAt: timestampSchema,
})
export type AutomationEngineCancellationResult = z.infer<
typeof automationEngineCancellationResultSchema
>
export interface AutomationEngineObserveOptions {
/** Exclusive durable cursor. Omit it to observe from sequence one. */
afterSequence?: number
signal?: AbortSignal
}
export interface AutomationEngineAdapter {
capabilities(): Promise<AutomationEngineCapabilityDeclaration>
admit(
request: AutomationEngineAdmissionRequest,
): Promise<AutomationEngineAdmissionReceipt>
observe(
receipt: AutomationEngineAdmissionReceipt,
options?: AutomationEngineObserveOptions,
): AsyncIterable<AutomationEngineEvent>
read(
receipt: AutomationEngineAdmissionReceipt,
): Promise<AutomationEngineReadResult | null>
cancel(
receipt: AutomationEngineAdmissionReceipt,
): Promise<AutomationEngineCancellationResult>
}
export interface AutomationEngineEventSequenceValidator {
readonly cursor: number
accept(event: AutomationEngineEvent): void
}
/** Validates events before Den persists them and advances its durable cursor. */
export function createAutomationEngineEventSequenceValidator(
rawReceipt: AutomationEngineAdmissionReceipt,
afterSequence = 0,
): AutomationEngineEventSequenceValidator {
const receipt = automationEngineAdmissionReceiptSchema.parse(rawReceipt)
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
throw new Error("Automation engine event cursor must be a non-negative integer")
}
let cursor = afterSequence
const eventKeys = new Set<string>()
return {
get cursor() {
return cursor
},
accept(rawEvent) {
const event = automationEngineEventSchema.parse(rawEvent)
if (event.executionId !== receipt.executionId || event.runId !== receipt.runId) {
throw new Error("Automation engine event receipt mismatch")
}
if (event.sequence !== cursor + 1) {
throw new Error("Automation engine event sequence is not contiguous")
}
if (eventKeys.has(event.idempotencyKey)) {
throw new Error("Automation engine event idempotency key was repeated")
}
eventKeys.add(event.idempotencyKey)
cursor = event.sequence
},
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from "@openwork/types/automations"
export * from "./contracts.js"
export * from "./engine.js"
export * from "./ports.js"
export * from "./runner.js"
export * from "./schedule.js"
export * from "./state.js"
export * from "./tick.js"
+92
View File
@@ -0,0 +1,92 @@
import type {
Automation,
AutomationError,
AutomationRevision,
AutomationRun,
AutomationRunEvent,
AutomationRunEventType,
AutomationRunReceipt,
AutomationUsage,
CreateAutomationDefinition,
UpdateAutomation,
} from "@openwork/types/automations"
export type Awaitable<T> = T | Promise<T>
export interface AutomationListItem {
automation: Automation
revision: AutomationRevision
latestRun: AutomationRun | null
}
export type AutomationClaimResult =
| { kind: "claimed"; run: AutomationRun; revision: AutomationRevision }
| { kind: "duplicate"; run: AutomationRun }
| { kind: "overlap"; run: AutomationRun }
/** Durable repository boundary. Claims and revision updates must be transactional. */
export interface AutomationRepository {
create(input: {
organizationId: string
ownerMemberId: string
definition: CreateAutomationDefinition
now: number
}): Awaitable<AutomationListItem>
update(input: {
organizationId: string
ownerMemberId: string
automationId: string
changes: UpdateAutomation
now: number
}): Awaitable<AutomationListItem>
list(input: { organizationId: string; ownerMemberId: string; cursor?: string; limit: number }): Awaitable<{
items: AutomationListItem[]
nextCursor: string | null
}>
get(input: { organizationId: string; ownerMemberId: string; automationId: string }): Awaitable<AutomationListItem | null>
setState(input: {
organizationId: string
ownerMemberId: string
automationId: string
state: "active" | "inactive" | "archived"
now: number
}): Awaitable<AutomationListItem | null>
listDue(input: { now: number; limit: number }): Awaitable<AutomationListItem[]>
claim(input: {
automation: Automation
revision: AutomationRevision
trigger: "scheduled" | "recovery" | "manual"
scheduledFor: number | null
nonce?: string
leaseOwner: string
leaseMs: number
claimDeadlineMs?: number
now: number
}): Awaitable<AutomationClaimResult>
heartbeat(input: { runId: string; leaseOwner: string; leaseMs: number; now: number }): Awaitable<boolean>
appendEvent(input: {
runId: string
leaseOwner: string
type: AutomationRunEventType
payload: Record<string, unknown>
now: number
}): Awaitable<AutomationRunEvent>
complete(input: {
runId: string
leaseOwner: string
status: "succeeded" | "failed" | "cancelled" | "skipped"
resultSummary: string | null
usage: AutomationUsage
error: AutomationError | null
engineReceipt?: Record<string, unknown> | null
attempt?: number
now: number
}): Awaitable<AutomationRun>
recoverExpiredLeases(input: { now: number; limit: number }): Awaitable<AutomationRun[]>
requestCancellation(input: { organizationId: string; ownerMemberId: string; runId: string; now: number }): Awaitable<AutomationRun | null>
getRunReceipt(input: { organizationId: string; ownerMemberId: string; runId: string }): Awaitable<AutomationRunReceipt | null>
listRuns(input: { organizationId: string; ownerMemberId: string; automationId: string; cursor?: string; limit: number }): Awaitable<{
items: AutomationRun[]
nextCursor: string | null
}>
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import { AUTOMATION_DESKTOP_RUNNER_PRESENCE_WINDOW_MS } from "@openwork/types/automations"
import { desktopClaimDeadline, desktopRunnerConnected, missedDesktopRunMessage } from "./runner.js"
const MINUTE = 60_000
describe("desktop runner recovery", () => {
test("keeps an occurrence claimable long enough to survive a sleeping desktop", () => {
const now = Date.UTC(2026, 0, 5, 9)
// A daily 09:00 occurrence on a laptop that wakes at 09:07 still runs.
expect(desktopClaimDeadline({ now, windowMs: 15 * MINUTE, nextDueAt: Date.UTC(2026, 0, 6, 9) }))
.toBe(now + 15 * MINUTE)
expect(desktopClaimDeadline({ now, windowMs: 15 * MINUTE, nextDueAt: null }))
.toBe(now + 15 * MINUTE)
})
test("releases an occurrence before its own next one is due", () => {
const now = Date.UTC(2026, 0, 5, 10)
// A queued run makes the following occurrence overlap and skip, so an
// unclaimed hourly 10:00 run must not still hold the slot at 11:00.
expect(desktopClaimDeadline({ now, windowMs: 15 * MINUTE, nextDueAt: now + 5 * MINUTE }))
.toBe(now + 5 * MINUTE)
})
test("never shortens the window a deployment asked for", () => {
const now = Date.UTC(2026, 0, 5, 10)
expect(desktopClaimDeadline({ now, windowMs: 15 * MINUTE, nextDueAt: now + 10_000 }))
.toBe(now + MINUTE)
expect(desktopClaimDeadline({ now, windowMs: 30_000, nextDueAt: now + 10_000 }))
.toBe(now + 30_000)
})
test("treats a recently seen desktop as connected", () => {
const now = Date.UTC(2026, 0, 5, 10)
expect(desktopRunnerConnected({ lastSeenAt: null, now })).toBe(false)
expect(desktopRunnerConnected({ lastSeenAt: now - MINUTE, now })).toBe(true)
expect(desktopRunnerConnected({
lastSeenAt: now - AUTOMATION_DESKTOP_RUNNER_PRESENCE_WINDOW_MS - 1,
now,
})).toBe(false)
})
test("names the cause an operator can act on", () => {
const now = Date.UTC(2026, 0, 5, 10)
expect(missedDesktopRunMessage({ busy: true, lastSeenAt: now, now }))
.toBe("Missed — the desktop was busy with another Automation run.")
expect(missedDesktopRunMessage({ busy: false, lastSeenAt: null, now }))
.toBe("Missed — no desktop was connected.")
expect(missedDesktopRunMessage({ busy: false, lastSeenAt: now - MINUTE, now }))
.toBe("Missed — the connected desktop did not pick this up in time.")
})
})
+53
View File
@@ -0,0 +1,53 @@
import { AUTOMATION_DESKTOP_RUNNER_PRESENCE_WINDOW_MS } from "@openwork/types/automations"
/** Shortest window an occurrence stays claimable, whatever the deployment tunes. */
export const AUTOMATION_MIN_CLAIM_WINDOW_MS = 60_000
/**
* How long a desktop occurrence stays claimable. A desktop is a laptop that
* sleeps, restarts, and changes networks, so this is a recovery window rather
* than a liveness check: a desktop that comes back inside it still runs the
* occurrence instead of the operator finding a missed run.
*
* It never reaches the next occurrence, because a run that is still queued
* makes the following one overlap and skip — an unclaimed hourly 10:00 run has
* to release before 11:00 is due.
*/
export function desktopClaimDeadline(input: {
now: number
windowMs: number
nextDueAt: number | null
}): number {
const requested = input.now + input.windowMs
const bounded = input.nextDueAt === null ? requested : Math.min(requested, input.nextDueAt)
const floor = input.now + Math.min(input.windowMs, AUTOMATION_MIN_CLAIM_WINDOW_MS)
return Math.max(floor, bounded)
}
/**
* Presence is durable rather than live: registration refreshes it every few
* minutes and idle event streams deliberately avoid writing to the database,
* so a desktop counts as connected for a while after it was last seen.
*/
export function desktopRunnerConnected(input: { lastSeenAt: number | null; now: number }): boolean {
return input.lastSeenAt !== null
&& input.now - input.lastSeenAt <= AUTOMATION_DESKTOP_RUNNER_PRESENCE_WINDOW_MS
}
/**
* Why an occurrence was never claimed. One generic outcome hid a real defect
* for weeks, because an operator could not tell a desktop that was switched
* off from a runner that could not connect at all. Name the cause the operator
* can act on: a concurrent run is exact, and presence distinguishes a desktop
* that was absent from one that was there and stayed silent.
*/
export function missedDesktopRunMessage(input: {
busy: boolean
lastSeenAt: number | null
now: number
}): string {
if (input.busy) return "Missed — the desktop was busy with another Automation run."
return desktopRunnerConnected(input)
? "Missed — the connected desktop did not pick this up in time."
: "Missed — no desktop was connected."
}
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { createAutomationSchema, createCloudAutomationSchema, updateAutomationSchema } from "@openwork/types/automations"
import { automationRevisionDigest } from "./contracts"
const schedule = { kind: "daily" as const, timezone: "UTC", hour: 9, minute: 0 }
describe("saved Script Automations", () => {
test("accepts only cloud placement for a pinned saved Script", () => {
const definition = {
name: "Launch briefing",
schedule,
action: {
kind: "saved_script" as const,
script: {
pluginId: "plugin_1",
configObjectId: "script_1",
configObjectVersionId: "version_1",
},
input: { topic: "launch" },
},
executionTarget: "cloud" as const,
}
expect(createAutomationSchema.safeParse(definition).success).toBe(true)
expect(createAutomationSchema.safeParse({ ...definition, executionTarget: "desktop" }).success).toBe(false)
})
test("binds creation surface to an immutable execution target", () => {
const cloudAgent = {
name: "Morning brief",
schedule,
action: {
kind: "agent" as const,
instructions: "Prepare the morning brief",
model: { providerId: "provider_1", modelId: "model_1" },
},
executionTarget: "cloud" as const,
}
expect(createAutomationSchema.safeParse(cloudAgent).success).toBe(true)
expect(createAutomationSchema.safeParse({ ...cloudAgent, executionTarget: "desktop" }).success).toBe(false)
expect(createAutomationSchema.safeParse({
name: "Desktop brief",
instructions: "Prepare the desktop brief",
schedule,
model: { providerId: "provider_1", modelId: "model_1" },
}).success).toBe(true)
expect(updateAutomationSchema.safeParse({ executionTarget: "cloud" }).success).toBe(true)
expect(createCloudAutomationSchema.safeParse({
name: cloudAgent.name,
schedule,
action: cloudAgent.action,
}).data?.executionTarget).toBe("cloud")
expect(createCloudAutomationSchema.safeParse({
name: cloudAgent.name,
schedule,
action: cloudAgent.action,
executionTarget: "desktop",
}).success).toBe(false)
})
test("keeps legacy agent creation compatible and versions exact script identities", () => {
expect(createAutomationSchema.safeParse({
name: "Legacy",
instructions: "Prepare a briefing",
schedule,
model: { providerId: "openwork", modelId: "model" },
}).success).toBe(true)
const common = {
instructions: "Execute the pinned saved Code Mode script.",
schedule,
model: { providerId: "opencode", modelId: "big-pickle" },
maximumRuntimeMs: 60_000,
executionTarget: "cloud" as const,
}
const action = {
kind: "saved_script" as const,
script: { pluginId: "plugin_1", configObjectId: "script_1", configObjectVersionId: "version_1" },
input: { topic: "launch" },
}
expect(automationRevisionDigest({ ...common, action }))
.not.toBe(automationRevisionDigest({
...common,
action: { ...action, script: { ...action.script, configObjectVersionId: "version_2" } },
}))
})
})
+204
View File
@@ -0,0 +1,204 @@
import {
automationScheduleSchema,
type AutomationSchedule,
} from "@openwork/types/automations"
const DAY_MS = 24 * 60 * 60 * 1_000
const SEARCH_WINDOW_HOURS = 18
type LocalDate = { year: number; month: number; day: number }
type LocalDateTime = LocalDate & {
hour: number
minute: number
weekday: number
}
const formatters = new Map<string, Intl.DateTimeFormat>()
function formatter(timezone: string): Intl.DateTimeFormat {
const existing = formatters.get(timezone)
if (existing) return existing
const created = new Intl.DateTimeFormat("en-US-u-ca-gregory", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
weekday: "short",
})
formatters.set(timezone, created)
return created
}
function localDateTime(timestamp: number, timezone: string): LocalDateTime {
const values = new Map(
formatter(timezone)
.formatToParts(new Date(timestamp))
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
)
const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(
values.get("weekday") ?? "",
)
return {
year: Number(values.get("year")),
month: Number(values.get("month")),
day: Number(values.get("day")),
hour: Number(values.get("hour")),
minute: Number(values.get("minute")),
weekday,
}
}
function addLocalDays(date: LocalDate, days: number): LocalDate {
const next = new Date(Date.UTC(date.year, date.month - 1, date.day + days))
return {
year: next.getUTCFullYear(),
month: next.getUTCMonth() + 1,
day: next.getUTCDate(),
}
}
function localKey(
value: Pick<LocalDateTime, "year" | "month" | "day" | "hour" | "minute">,
): number {
return Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute)
}
function sameLocalDate(left: LocalDate, right: LocalDate): boolean {
return (
left.year === right.year &&
left.month === right.month &&
left.day === right.day
)
}
function resolveLocalOccurrence(
date: LocalDate,
hour: number,
minute: number,
timezone: string,
): { timestamp: number; shifted: boolean } | null {
const nominal = Date.UTC(date.year, date.month - 1, date.day, hour, minute)
const start = nominal - SEARCH_WINDOW_HOURS * 60 * 60 * 1_000
const end = nominal + SEARCH_WINDOW_HOURS * 60 * 60 * 1_000
const targetKey = Date.UTC(date.year, date.month - 1, date.day, hour, minute)
let shifted: number | null = null
for (let candidate = start; candidate <= end; candidate += 60_000) {
const local = localDateTime(candidate, timezone)
if (!sameLocalDate(local, date)) continue
const key = localKey(local)
if (key === targetKey) return { timestamp: candidate, shifted: false }
if (key > targetKey && (shifted === null || candidate < shifted)) {
shifted = candidate
}
}
return shifted === null ? null : { timestamp: shifted, shifted: true }
}
function isScheduledDay(
schedule: AutomationSchedule,
weekday: number,
): boolean {
return (
schedule.kind === "daily" ||
(schedule.kind === "weekly" && schedule.daysOfWeek.includes(weekday))
)
}
export function assertAutomationTimezone(timezone: string): void {
try {
formatter(timezone).format(new Date(0))
} catch {
throw new RangeError(`Invalid IANA timezone: ${timezone}`)
}
}
export interface AutomationOccurrenceSearchOptions {
after: number
count?: number
}
export function automationOccurrences(
input: AutomationSchedule,
options: AutomationOccurrenceSearchOptions,
): { occurrences: number[]; warnings: string[] } {
const schedule = automationScheduleSchema.parse(input)
assertAutomationTimezone(schedule.timezone)
const count = Math.max(0, Math.min(options.count ?? 5, 5))
if (count === 0) {
return { occurrences: [], warnings: [] }
}
if (schedule.kind === "once") {
return { occurrences: schedule.at > options.after ? [schedule.at] : [], warnings: [] }
}
const after = Math.floor(options.after)
const start = localDateTime(after, schedule.timezone)
const occurrences: number[] = []
const warnings = new Set<string>()
for (let offset = 0; offset < 370 && occurrences.length < count; offset += 1) {
const date = addLocalDays(start, offset)
const weekday = new Date(
Date.UTC(date.year, date.month - 1, date.day),
).getUTCDay()
if (!isScheduledDay(schedule, weekday)) continue
const resolved = resolveLocalOccurrence(
date,
schedule.hour,
schedule.minute,
schedule.timezone,
)
if (!resolved || resolved.timestamp <= after) continue
if (resolved.shifted) {
warnings.add(
`A wall-clock occurrence falls inside a daylight-saving transition and was shifted to the next valid minute in ${schedule.timezone}.`,
)
}
occurrences.push(resolved.timestamp)
}
return { occurrences, warnings: [...warnings] }
}
export function nextAutomationOccurrence(
schedule: AutomationSchedule,
after: number,
): number | null {
return automationOccurrences(schedule, { after, count: 1 }).occurrences[0] ?? null
}
export function previewAutomationSchedule(
input: AutomationSchedule,
options: { after?: number; generatedAt?: number } = {},
): { schedule: AutomationSchedule; generatedAt: number; occurrences: number[]; warnings: string[] } {
const generatedAt = Math.floor(options.generatedAt ?? Date.now())
const schedule = automationScheduleSchema.parse(input)
const result = automationOccurrences(schedule, {
after: Math.floor(options.after ?? generatedAt),
count: 5,
})
return {
schedule,
generatedAt,
occurrences: result.occurrences,
warnings: result.warnings,
}
}
/** Returns at most the latest missed occurrence; older backlog is never replayed. */
export function recoverableAutomationOccurrence(
schedule: AutomationSchedule,
input: { after: number; now: number },
): number | null {
const occurrences = automationOccurrences(schedule, { after: input.after, count: 5 }).occurrences
.filter((occurrence) => occurrence <= input.now)
return occurrences.at(-1) ?? null
}
export const AUTOMATION_DAY_MS = DAY_MS
+24
View File
@@ -0,0 +1,24 @@
import type { AutomationRunStatus, AutomationState } from "@openwork/types/automations"
const transitions: Readonly<Record<AutomationState, readonly AutomationState[]>> = {
active: ["inactive", "needs_attention", "archived"],
inactive: ["active", "needs_attention", "archived"],
needs_attention: ["active", "inactive", "archived"],
archived: [],
}
const terminalRunStatuses = new Set<AutomationRunStatus>([
"succeeded", "failed", "cancelled", "skipped",
])
export function canTransitionAutomation(from: AutomationState, to: AutomationState): boolean {
return from === to || transitions[from].includes(to)
}
export function assertAutomationTransition(from: AutomationState, to: AutomationState): void {
if (!canTransitionAutomation(from, to)) throw new Error(`Invalid Automation transition: ${from} -> ${to}`)
}
export function isTerminalAutomationRunStatus(status: AutomationRunStatus): boolean {
return terminalRunStatuses.has(status)
}
+74
View File
@@ -0,0 +1,74 @@
import type { AutomationRepository } from "./ports.js"
export * from "./engine-testing.js"
export async function verifyAutomationRepositoryConformance(
repository: AutomationRepository,
): Promise<string[]> {
const checked: string[] = []
const definition = {
name: "Conformance Automation",
instructions: "Return the word ready.",
schedule: { kind: "daily" as const, timezone: "UTC", hour: 9, minute: 0 },
model: { providerId: "provider_conformance", modelId: "model_conformance" },
}
const created = await repository.create({
organizationId: "org_conformance",
ownerMemberId: "member_conformance",
definition,
now: 1_000,
})
if (created.automation.state !== "active") throw new Error("creation must be active")
checked.push("transactional active creation")
const fetched = await repository.get({
organizationId: created.automation.organizationId,
ownerMemberId: created.automation.ownerMemberId,
automationId: created.automation.id,
})
if (fetched?.revision.id !== created.revision.id) throw new Error("created revision was not durable")
checked.push("durable initial revision")
const isolated = await repository.get({
organizationId: "org_other",
ownerMemberId: created.automation.ownerMemberId,
automationId: created.automation.id,
})
if (isolated !== null) throw new Error("organization isolation failed")
checked.push("organization isolation")
const updated = await repository.update({
organizationId: created.automation.organizationId,
ownerMemberId: created.automation.ownerMemberId,
automationId: created.automation.id,
changes: { instructions: "Return the word updated." },
now: 2_000,
})
if (updated.revision.version !== created.revision.version + 1) throw new Error("revision version did not increase")
if (updated.revision.id === created.revision.id) throw new Error("revision was mutated in place")
checked.push("immutable revisions")
const firstClaim = await repository.claim({
automation: updated.automation,
revision: updated.revision,
trigger: "scheduled",
scheduledFor: updated.automation.nextDueAt,
leaseOwner: "replica_a",
leaseMs: 30_000,
now: 3_000,
})
const secondClaim = await repository.claim({
automation: updated.automation,
revision: updated.revision,
trigger: "recovery",
scheduledFor: updated.automation.nextDueAt,
leaseOwner: "replica_b",
leaseMs: 30_000,
now: 3_000,
})
if (firstClaim.kind !== "claimed" || secondClaim.kind === "claimed") {
throw new Error("one occurrence was claimed by multiple replicas")
}
checked.push("scheduled and recovery claim deduplication")
return checked
}
+23
View File
@@ -0,0 +1,23 @@
import type { Automation } from "@openwork/types/automations"
import type { AutomationListItem } from "./ports.js"
export function selectDueAutomations(
candidates: readonly AutomationListItem[],
input: { now: number; limit: number },
): AutomationListItem[] {
const limit = Math.max(1, Math.min(Math.floor(input.limit), 500))
return candidates
.filter(({ automation }) => automation.state === "active" && automation.nextDueAt !== null && automation.nextDueAt <= input.now)
.sort((left, right) =>
(left.automation.nextDueAt ?? 0) - (right.automation.nextDueAt ?? 0)
|| left.automation.id.localeCompare(right.automation.id))
.slice(0, limit)
}
export function hasActiveRun(runs: readonly { status: string }[]): boolean {
return runs.some(({ status }) => status === "claimed" || status === "running")
}
export function nextAutomationAfterClaim(automation: Automation, nextDueAt: number | null, now: number): Automation {
return { ...automation, nextDueAt, latestRunAt: now, updatedAt: now }
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "tsup"
export default defineConfig({
clean: true,
dts: true,
entry: {
index: "src/index.ts",
testing: "src/testing.ts",
},
external: ["@openwork/types", "zod"],
format: ["esm"],
target: "es2022",
})