源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
@@ -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.")
|
||||
})
|
||||
})
|
||||
@@ -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" } },
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
Reference in New Issue
Block a user