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
53 changes: 38 additions & 15 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ imports them or detects the active runtime. The absence of a provider is
reported rather than answered with an empty store: a run that appears to start
and retains nothing has not started.

A storage handle is owned by the scope that opened it. Its connection closes
through ordinary teardown, and a call after that scope closes fails rather than
reopening anything.
A storage handle is a lease owned by the scope that opened it. Lease teardown
makes that handle unusable without closing the run's physical connection or
invalidating another handle. The Deno provider owns the authoritative
SQLite/DOFS connection for each canonical workflow-run database path and closes
it at provider-scope teardown after its child scopes finish.

### Identity is separate from retrieval

Expand Down Expand Up @@ -198,6 +200,15 @@ A stop reason is a categorical host code or a reference to an already-filtered
journal event. Arbitrary exception text is never duplicated outside the journal
that filtered it.

WorkflowRun schema version 1 is complete in place. It contains the run records,
filtered journal, pinned Cloudflare DOFS version-5 structure, immutable
Workspace-root tables, current-root state, and a non-null Workspace-root
association on every journal event. A fresh run starts with one
content-addressed root manifest describing only the root directory, empty
retained manifest and blob reference sets, and the corresponding root-only DOFS
frontier. This storage layer recognizes that canonical empty frontier; it does
not publish nonempty roots or Workspace mutations.

Which status transitions are legal, and what a caller may do to a run in each
of them, is lifecycle policy applied above storage.

Expand All @@ -215,10 +226,13 @@ public identifier.
### One database at a time, and a transaction a caller can hold

Operations on one run's storage are serialized, and each runs inside a
transaction. Turns belong to the storage a run lives in rather than to a handle
on it, so two handles for one run take turns instead of contending β€” a host
whose storage is reached synchronously would otherwise stop while a second
handle waited for a transaction the first one cannot resume to finish.
transaction. The Deno provider's per-path entry owns the one physical SQLite
connection, the one Cloudflare DOFS wrapper and Workspace filesystem, the
cooperative connection queue, and the savepoint allocator. Turns belong to that
entry rather than to a handle, so two leases for one run take turns instead of
contending β€” a host whose storage is reached synchronously would otherwise stop
while a second handle waited for a transaction the first one cannot resume to
finish. Different workflow-run paths have independent entries.

A caller that must publish several changes together holds the transaction
itself and receives a handle for taking part in it. Enlistment travels with
Expand Down Expand Up @@ -253,6 +267,10 @@ it is not shaped like is damage rather than a version this build has not
learned. Storage is initialized only when it is pristine: something that merely
looks unused is not.

Complete version 1 is the first XMD schema. A database carrying the XMD
application identity with schema version zero is a partial initialization and
therefore damage, not a supported historical version.

Records are held to what they mean and not only to the types they are stored
in. A retained timestamp is an instant, a retained identity is not empty, and
normalized props are an object.
Expand Down Expand Up @@ -366,12 +384,17 @@ again.
## Local Workspace topology

The local workflow host owns SQLite directly in Deno and reuses Cloudflare's
DOFS filesystem layer behind the provider-neutral Workspace boundary. The
journal and DOFS adapter share the operation-scoped transaction. One
authoritative host-owned DOFS connection serves each workflow database, and the
host serializes its Workspace-local effect transactions. A second long-lived
DOFS connection is not a coherent reader because provider caches may retain
negative entries across another connection's commit.
DOFS filesystem layer behind the provider-neutral Workspace boundary. One
authoritative provider-owned connection entry serves each canonical workflow
database path until provider teardown. The journal and DOFS adapter use that
same SQLite connection; Cloudflare's synchronous initialization transactions
become uniquely named savepoints inside XMD's caller-owned transaction. A
second long-lived DOFS connection is not a coherent reader because provider
caches may retain negative entries across another connection's commit.

Complete schema version 1 retains the canonical empty Workspace root and its
root-only live frontier. The provider exposes no Workspace mutation operation
and publishes no nonempty retained root at this layer.

The initial topology requires neither writable FUSE nor native subprocess
access and does not bundle `workerd`. A Cloudflare-hosted or workerd-backed
Expand Down Expand Up @@ -660,7 +683,7 @@ Status is measured against main.
| `Expansion` / `getExpansion()` | describes the current logical element expansion | built on main |
| `useWorkflow()` / `getWorkflowRun()` | associates one document execution with a workflow run | built on main |
| `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main |
| workflow run storage | creates or compatibly finds one run by public run ID, and retains its identity, state, document executions and filtered journal | built on main |
| workflow run storage | creates or compatibly finds one run by public run ID, and retains its identity, state, document executions, filtered journal and canonical empty Workspace root through one provider-owned connection entry | built on the #365 stack; Workspace mutation publication is unbuilt |
| caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main |
| `API.Service` / `startService()` | creates an authenticated, supervised loopback service attachment through a provider-neutral operation | built on main |
| `service=<binding>` | publishes the attachment's endpoint into the live binding overlay for its invocation | built on main |
Expand All @@ -671,7 +694,7 @@ Status is measured against main.
| Repository / Worktree / transactional Git effects | compose named checkouts and publish local mutations with their journal result | defined in `specs/workflow-workspace-spec.md`, unbuilt |
| workflow inspection and history fork | reads status/history without advancing a run and creates a new run from a checkpoint | defined in `specs/workflow-workspace-spec.md`, unbuilt |
| read-only workflow Agent / generated XMD | lets an Agent inspect a derived view and propose constrained executable changes | defined in `specs/workflow-workspace-spec.md`, unbuilt |
| Deno-local DOFS provider | stores the authoritative local Workspace in SQLite | persistence POC complete; effect-transaction integration unbuilt |
| Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path and recognizes the complete-v1 canonical empty frontier | built on the #365 stack; nonempty roots and effect-transaction integration are unbuilt |
| scoped Worker Shell | executes `just-bash` through the Workspace adapter inside a Deno Worker | containment and effect-transaction POCs complete (#351, #357); production integration unbuilt |
| `<Retry max timeout>` | retry a region until it completes | defined, unbuilt |
| suspension effect | suspend durably | defined, unbuilt |
Expand Down
1 change: 1 addition & 0 deletions packages/workflow/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export {
WorkflowDatabaseFormatError,
WorkflowDefinitionError,
WorkflowDocumentExecutionError,
WorkflowIncompleteVersionOneError,
WorkflowRecordMalformedError,
WorkflowRequestError,
WorkflowRunConflictError,
Expand Down
140 changes: 140 additions & 0 deletions packages/workflow/src/deno/connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { DatabaseSync } from "node:sqlite";
import { resolve } from "node:path";
import { Database as CloudflareDatabase } from "../../vendor/cloudflare-computer-dofs/generated/storage.js";
import { WorkspaceFilesystem } from "../../vendor/cloudflare-computer-dofs/generated/fs/filesystem.js";
import type {
DurableObjectStorageLike,
SQLCursorLike,
SQLStorageLike,
} from "../../vendor/cloudflare-computer-dofs/generated/types.d.ts";
import { type ConnectionLock, createConnectionLock } from "./lock.ts";
import { createSavepointManager, type SavepointManager } from "./savepoints.ts";

export interface RunConnection {
readonly path: string;
readonly database: DatabaseSync;
readonly dofs: CloudflareDatabase;
readonly filesystem: WorkspaceFilesystem;
readonly lock: ConnectionLock;
readonly savepoints: SavepointManager;
transactionOpen: boolean;
close(): void;
}

export interface WorkflowRunConnections {
at(path: string): RunConnection;
close(): void;
}

class SqliteStorage implements SQLStorageLike {
readonly database: DatabaseSync;
readonly savepoints: () => SavepointManager;

constructor(database: DatabaseSync, savepoints: () => SavepointManager) {
this.database = database;
this.savepoints = savepoints;
}

exec<Row extends object = Record<string, unknown>>(
query: string,
...bindings: unknown[]
): SQLCursorLike<Row> {
const statement = this.database.prepare(query);
const rows = Reflect.apply(statement.all, statement, bindings);
return {
toArray(): Row[] {
return rows;
},
};
}
}

function createConnection(path: string): RunConnection {
const database = new DatabaseSync(path);
try {
database.exec("PRAGMA foreign_keys = ON");
database.exec("PRAGMA busy_timeout = 5000");
} catch (error) {
database.close();
throw error;
}

let open = true;
const connection: {
savepoints: SavepointManager | undefined;
transactionOpen: boolean;
} = { savepoints: undefined, transactionOpen: false };
const storage = new SqliteStorage(database, () => {
const savepoints = connection.savepoints;
if (savepoints === undefined) {
throw new WorkflowConnectionStateError("the savepoint manager is not installed");
}
return savepoints;
});
const durableStorage: DurableObjectStorageLike = {
sql: storage,
transactionSync<T>(closure: () => T): T {
return storage.savepoints().synchronous(closure);
},
};
const dofs = new CloudflareDatabase(durableStorage);
const savepoints = createSavepointManager(database, () => connection.transactionOpen);
connection.savepoints = savepoints;

return {
path,
database,
dofs,
filesystem: new WorkspaceFilesystem(dofs),
lock: createConnectionLock(),
savepoints,
get transactionOpen() {
return connection.transactionOpen;
},
set transactionOpen(value: boolean) {
connection.transactionOpen = value;
},
close() {
if (open) {
open = false;
database.close();
}
},
};
}

export class WorkflowConnectionStateError extends Error {
override name = "WorkflowConnectionStateError";
}

export function createWorkflowRunConnections(): WorkflowRunConnections {
const entries = new Map<string, RunConnection>();
let open = true;

return {
at(path: string): RunConnection {
if (!open) {
throw new WorkflowConnectionStateError("the workflow storage provider has closed");
}
const canonical = resolve(path);
const existing = entries.get(canonical);
if (existing !== undefined) {
return existing;
}
const created = createConnection(canonical);
entries.set(canonical, created);
return created;
},

close(): void {
if (!open) {
return;
}
open = false;
for (const entry of entries.values()) {
entry.close();
}
entries.clear();
},
};
}
Loading
Loading