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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-runtime",
"version": "0.13.0",
"version": "0.13.1",
"description": "Reusable runtime lifecycle for domain-specific agents.",
"homepage": "https://github.com/tangle-network/agent-runtime#readme",
"repository": {
Expand Down
5 changes: 5 additions & 0 deletions src/durable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export { canonicalHash, canonicalJson, deriveWorkerId, manifestHash, stepId } fr
export { InMemoryDurableRunStore } from './in-memory-store'
export type { DurableContext, RunDurableInput, RunDurableResult } from './runner'
export { runDurable } from './runner'
// Canonical D1 schema string + current version. Consumers wire via
// await env.DB.exec(DURABLE_SCHEMA_SQL)
// during one-time bootstrap. `src/durable/schema.sql` is the source of
// truth; `schema.ts` is the build-bundled string that ships in dist/.
export { DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION } from './schema'
export type {
DurableRunManifest,
DurableRunStore,
Expand Down
87 changes: 87 additions & 0 deletions src/durable/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* The durable-runs SQL schema as a string constant. Inlined so consumers
* (Cloudflare Workers via D1) can apply it without bundling a `.sql` file:
*
* import { DURABLE_SCHEMA_SQL } from '@tangle-network/agent-runtime'
* await env.DB.exec(DURABLE_SCHEMA_SQL)
*
* The canonical source is `src/durable/schema.sql` — this string MUST stay
* byte-identical to it. The build does not copy `.sql` files into `dist/`,
* so the constant is the only path consumers have. A unit test asserts the
* two stay in sync (`durable-schema.test.ts`).
*
* `DURABLE_SCHEMA_VERSION` reflects the latest migration version applied by
* this string. Bump it on every backwards-incompatible change AND add a new
* migration entry to durable_schema_info instead of mutating prior rows.
*/

export const DURABLE_SCHEMA_VERSION = 1

export const DURABLE_SCHEMA_SQL = `-- Durable-run substrate — versioned schema for D1 / SQLite.
--
-- Apply once per database. Subsequent migrations append; never rewrite a
-- prior version. See \`durable_schema_info\` for the migration trail.
--
-- Concurrency notes for D1:
-- - SQLite supports UNIQUE constraints for first-emit-wins (\`durable_events\`
-- PK is (run_id, key) — duplicate insert raises, caller treats as "already
-- emitted").
-- - Lease takeover happens via a conditional UPDATE: we only claim the lease
-- if (lease_holder_id IS NULL OR lease_expires_at < :now) — atomic under
-- SQLite's row-level locking.
-- - All timestamps stored as ISO-8601 TEXT for cross-platform consistency.
-- - \`result_json\` / \`error_json\` / \`outcome_json\` / \`payload_json\` are
-- JSON-encoded TEXT; the application enforces canonical-JSON discipline at
-- the boundary so the store stays type-agnostic.

CREATE TABLE IF NOT EXISTS durable_schema_info (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS durable_runs (
run_id TEXT PRIMARY KEY,
manifest_hash TEXT NOT NULL,
project_id TEXT NOT NULL,
scenario_id TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed','suspended')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
lease_holder_id TEXT,
lease_expires_at TEXT,
outcome_json TEXT,
step_count INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_durable_runs_project_status ON durable_runs(project_id, status);
CREATE INDEX IF NOT EXISTS idx_durable_runs_lease_expires ON durable_runs(lease_expires_at);

CREATE TABLE IF NOT EXISTS durable_steps (
run_id TEXT NOT NULL,
step_index INTEGER NOT NULL,
intent TEXT NOT NULL,
kind TEXT NOT NULL,
input_hash TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
result_json TEXT,
error_json TEXT,
started_at TEXT,
completed_at TEXT,
PRIMARY KEY (run_id, step_index)
);

CREATE INDEX IF NOT EXISTS idx_durable_steps_status ON durable_steps(run_id, status);

CREATE TABLE IF NOT EXISTS durable_events (
run_id TEXT NOT NULL,
key TEXT NOT NULL,
payload_json TEXT,
emitted_at TEXT NOT NULL,
PRIMARY KEY (run_id, key)
);

INSERT OR IGNORE INTO durable_schema_info (version, applied_at)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
`
24 changes: 24 additions & 0 deletions src/durable/tests/schema-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Guard: the inlined `DURABLE_SCHEMA_SQL` constant must stay byte-identical
* to `src/durable/schema.sql`. If the .sql file is the source of truth, the
* constant cannot drift — otherwise consumers and tests see different
* schemas and the D1 store silently goes inconsistent.
*/

import { readFileSync } from 'node:fs'

import { describe, expect, it } from 'vitest'

import { DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION } from '../schema'

describe('durable schema', () => {
it('inlined SQL constant matches schema.sql byte-for-byte', () => {
const fileSql = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
expect(DURABLE_SCHEMA_SQL).toBe(fileSql)
})

it('schema version is positive integer', () => {
expect(DURABLE_SCHEMA_VERSION).toBeGreaterThan(0)
expect(Number.isInteger(DURABLE_SCHEMA_VERSION)).toBe(true)
})
})
Loading