Tide

tide serve

Run the HTTP control surface over the workflow runner.

tide serve runs a long-lived runner, a first-class human-task operations console, and an HTTP /v1 control surface. Instead of invoking tide run per workflow, you enqueue invocations over HTTP, inspect and resolve human waits in the browser, and observe the invocation resume. With the default persistent store, suspended invocations survive a restart.

Start the server

tide serve --workflows workflows --port 4100
FlagDefaultWhat it does
--port <port>4100TCP port to bind on 127.0.0.1.
--workflows <dir>workflowsWorkflow source root. A request for workflow <name> resolves <dir>/<name>/main.ts.
--registry <url>TIDE_REGISTRY_URLRegistry source for exact name@version references. Unversioned names remain local.
--store <memory|fs|sqlite|postgres>fsWorkflow journal store. Keep a persistent backend for restart recovery.
--workers <n>1Worker-pool size. >1 runs invocations in parallel (one V8 isolate per OS thread).
--argon <url>Serve state.* against a remote ox runtime serve /v1 endpoint instead of the embedded Argon backend.

The shipped binary serves with the embedded Argon backend by default; --argon <url> switches to a remote runtime. The server runs until the process is terminated.

Registry-free production control service

tide-control-service is a locked-down remote-Argon binary for one exact workflow package selected by deployment configuration. It admits only the NAME@VERSION passed with --allowed-workflow; unversioned, other-name, and other-version requests refuse before queue admission. It does not accept a registry URL or shared registry token.

Build the immutable local package with the same deterministic package builder used by tide publish:

tide package \
  --name example-workflow --version 1.0.0 --entry main.ts \
  --output /release/example-workflow-1.0.0.tar --json \
  /release/example-workflow-project

Start the service with explicit CLI flags or their environment fallbacks:

tide-control-service \
  --token-file /run/secrets/tide-control-token \
  --bind 127.0.0.1:7790 \
  --state-dir /var/lib/tide-control \
  --local-package /release/example-workflow-1.0.0.tar \
  --local-package-digest sha256:<64-lowercase-hex> \
  --local-package-size <bytes> \
  --allowed-workflow example-workflow@1.0.0 \
  --argon-origin http://127.0.0.1:7780 \
  --store fs --workers 1
FlagEnvironmentRequired
--token-fileTIDE_SERVER_TOKEN_FILEyes
--bindTIDE_SERVER_BINDyes; loopback only
--state-dirTIDE_SERVER_STATE_DIRyes
--local-packageTIDE_LOCAL_PACKAGEyes
--local-package-digestTIDE_LOCAL_PACKAGE_DIGESTyes
--local-package-sizeTIDE_LOCAL_PACKAGE_SIZEyes
--allowed-workflowTIDE_ALLOWED_WORKFLOWyes; exact NAME@VERSION matching the package manifest
--argon-originTIDE_ARGON_ORIGINyes
--storeTIDE_SERVER_STOREno; default fs
--workersTIDE_SERVER_WORKERSno; default 1

The state root owns queue/, store/, and package-cache/. The service rejects process-local memory storage. The token file must be a regular non-symlink file with no group/other permissions. The package must be a regular non-symlink file, not group/world writable, and must match the configured size, digest, manifest name, and manifest version within the shared 128 MiB bounds. Each workflow resolution revalidates every extracted entrypoint and import against the retained verified package bytes, repairing the private cache or refusing before execution. The filesystem-store startup gate rejects links or special files anywhere below store/ and completes a write, file and directory fsync, exact read, removal, and removal fsync probe below store/invocations/. The filesystem queue applies the same recursive custody checks, then uses an isolated private directory to admit, reopen, and verify one exact pending record before durably removing the probe; the real queue is opened only afterward. TIDE_SERVER_TOKEN is rejected so the token does not enter the plaintext environment.

Startup binds no socket until the durable queue/store open, the package is verified and materialized, the token is valid, and the locked remote Argon schema/module/tenant identity passes read-only /v1 checks. It does not create an Argon fork. GET /health then returns { "status": "ready" }; GET /ready includes non-secret package, storage, and Argon identity evidence.

Open http://127.0.0.1:4100 for the embedded Dispatch Ledger. It lists real pending tasks from the durable queue and journals—there is no separate frontend process or sample data. Select a task to inspect its workflow-authored context, choose a configured action (or enter one when the workflow leaves actions open), edit the JSON decision payload, add a comment, confirm, and follow the invocation through resuming to completion, failure, or its next wait.

The console checks every eight seconds while its tab is visible, supports manual refresh and J/K queue navigation, and keeps in-progress form input intact.

Workers

--workers sizes the pool of OS threads, each owning one V8 isolate, all draining the shared queue. --workers 1 (the default) runs one invocation's event loop at a time; --workers 4 runs up to four invocations in parallel. Every worker shares one backend handle, so they dispatch state.* against the same durable world.

Workers are internal to the server process and start by default; a separate worker command is not required. Tide does not currently expose external queue claims, leases, or heartbeats, so this is an in-process scaling model rather than a distributed worker protocol.

The /v1 surface

Errors use the envelope { error: { code, message }, requestId }, mirroring Argon's runtime serve.

Enqueue an invocation

POST /v1/workflows/{workflow}/invocations
POST /v1/workflows/{name}/versions/{version}/invocations

Body: { "invocationId"?: <id>, "input"?: <json> }. Supply a stable invocationId for idempotent admission. It accepts 1–128 ASCII letters, digits, -, or _. Input is canonicalized before admission. An unknown workflow name is a clean 404. A new run returns 202 Accepted:

{ "invocationId": "…", "status": "pending" }

Repeating an exact invocation id + pinned workflow identity + canonical input returns the existing lifecycle with 200 OK and does not requeue. Reusing the id with different workflow coordinates or input returns 409 invocation_conflict.

curl -s -X POST http://127.0.0.1:4100/v1/workflows/onboard/invocations \
  -H 'content-type: application/json' \
  -d '{"input":{"company":"Acme","name":"Ada","salary":120000}}'

Check status

GET /v1/invocations/{id}?logCursor=0&logLimit=100
{
  "invocationId": "…",
  "status": "pending | running | settlement_pending | settlement_running | settlement_blocked | completed | failed | rejected | legacy_unproven | suspended",
  "error": "…",                       // present on failed
  "pending": { "waitOrdinal": 0, "taskId": "…" },  // present on suspended
  "workflow": { "name": "onboard", "version": "1.2.0", "digest": "sha256:…" },
  "logs": [{ "sequence": 0, "level": "log", "message": "…" }],
  "nextLogCursor": 1,
  "logsRemaining": false,
  "result": { "…": "…" },             // completed only
  "resultDigest": "sha256:…",          // completed typed result only
  "settlement": "promote_on_success | discard_on_success",
  "settlementBinding": {
    "invocationId": "…",
    "workflowRef": "rebalance@1.2.0",
    "workflowName": "rebalance",
    "workflowVersion": "1.2.0",
    "workflowDigest": "sha256:…",
    "projectDigest": "sha256:…",
    "backend": { "backend": "argon", "target": "https://…", "tenant": "test0", "fork": "tide-run-…", "schemaIdentity": "sha256:…", "moduleIdentity": "sha256:…" }
  },
  "settlementIntent": { "binding": { "…": "same exact binding" }, "disposition": "promote | discard", "runStatus": "completed | failed" },
  "settlementReceipt": {
    "backend": "argon",
    "desiredDisposition": "promote",
    "terminalStatus": "promoted",
    "settledAt": "…",
    "tenant": "test0",
    "fork": "tide-run-…",
    "parentForkId": "main",
    "forkPointTx": "…"
  },
  "settlementError": "…",
  "settlementAttempts": 1,
  "updatedAt": "…"
}

A suspended invocation is parked on a hitl.wait; the pending object names the wait to resolve. Pass the previous nextLogCursor on the next poll to avoid duplicate console output. logLimit must be 1–500; logsRemaining tells a client to continue from nextLogCursor. Exact-version routes persist their resolved digest before enqueue. Local workflows receive a digest of their exact source tree. Restart and HITL resume refuse identity drift. A validated result and digest are durably retained with completed queue state and survive restart. Non-completed statuses omit them.

The exact invocation, workflow, project, backend kind/target, tenant, fork, and runtime schema/module identities are persisted atomically before backend preparation. Target discovery may open the exact runtime/world and validate its identity, but it cannot create the invocation fork. Preparation receives the persisted target and may create or attach the fork only through that same opened target lease; crossed target/world identities refuse. Embedded Argon records the artifact's stamped schema hash and the opened Connection module hash, matching the remote runtime identity surfaces. Terminal workflow output and its settlement intent reuse that binding and are persisted before settlement. settlement_pending is unclaimed settlement work; settlement_running is claimed settlement work. Restart requeues either phase without rerunning the workflow body. completed or terminal workflow failed is published only after the matching receipt is durable. The receipt records fork lifecycle coordinates; it does not claim a recoverable promote-event set. Target drift, a missing remote Argon fork, or an opposite terminal state refuses settlement. Settlement failures durably requeue up to three attempts, then enter settlement_blocked; no workflow body is rerun. legacy_unproven marks records written before exact binding/receipts whose backend terminal state cannot be proved. rejected is reserved for source or project refusal before backend preparation; it therefore has no backend state or settlement receipt. Tide persists the exact binding and a backend-preparation marker before calling the backend. After a crash, marked work remains nonterminal if its source or project cannot be recovered; it cannot be reclassified as rejected while a fork may exist. Marker-less records written by older Tide versions are treated conservatively as possibly backend-started. After process death during preparation, a deterministic fork already observed open at the exact bound target is reattached. An absent, contradictory, or otherwise unprovable fork outcome refuses before workflow effects.

List invocations

GET /v1/invocations?limit=25&cursor=…

limit must be 1–100. The response is stable by creation time and invocation id and returns an opaque typed nextCursor when another page exists:

{
  "items": [{
    "invocationId": "signal_run_123",
    "status": "completed",
    "workflow": { "name": "rebalance", "version": "1.2.0", "digest": "sha256:…" },
    "createdAt": "…",
    "updatedAt": "…",
    "resultDigest": "sha256:…",
    "settlement": "promote_on_success",
    "settlementReceipt": { "backend": "argon", "desiredDisposition": "promote", "terminalStatus": "promoted", "settledAt": "…" }
  }],
  "nextCursor": "…"
}

The list never returns invocation input, logs, or the structured result. Fetch one completed invocation for its bounded result. Like every /v1 route, the list requires the configured bearer token.

Resolve a human wait

POST /v1/invocations/{id}/resolve

Body: { "action": "approved", "payload": {…}, "comment": "…" } (action required; payload and comment optional). This records the decision into the journal and re-drives the invocation under the same id — the HTTP analogue of tide resume.

curl -s -X POST http://127.0.0.1:4100/v1/invocations/$ID/resolve \
  -H 'content-type: application/json' \
  -d '{"action":"approved","comment":"verified"}'

This invocation-level route remains available for compatibility. New operator clients should use the task-specific routes:

GET  /v1/hitl/tasks
GET  /v1/hitl/tasks/{taskId}
POST /v1/hitl/tasks/{taskId}/resolve

The list returns { "count", "tasks" } in stable creation/invocation order. Each task includes its task and invocation IDs, workflow, wait ordinal, invocation creation time (when recorded), status, and the safe workflow-authored hitl.wait request. It does not return invocation input or unrelated journal entries.

Task-specific resolution requires the current ordinal as a precondition:

{
  "waitOrdinal": 0,
  "action": "approve",
  "payload": { "verified": true },
  "comment": "Evidence checked"
}

Configured actions are enforced. Empty actions, malformed JSON, and actions outside that set produce structured 400 errors. A stale ordinal or a repeated resolution produces a structured 409 (stale_ordinal or already_resolved). Resolution is serialized within one server process across the exact journal check, completion append, and requeue. The current DurableStore contract has no compare-and-append primitive, so running multiple serve processes against the same store is not an atomic multi-instance deployment model.

Durability across restarts

With the filesystem queue and a persistent journal store, a suspended invocation survives a tide serve restart: bring the server back up, POST …/resolve, and it resumes from its journal. The runtime replays recorded operations rather than reissuing them; effect-intent reservation does not remove the crash window between an external host call and journal persistence.

Network security

Set TIDE_SERVER_TOKEN to require Authorization: Bearer … on every /v1 control route. Remote run, follow, and resume read that variable automatically. /health and static console assets remain unauthenticated; the console cannot call protected routes without an external operator client. Bearer comparison is constant-time, and structured access logs omit sensitive headers.

Tide does not terminate TLS or use cookies. Public deployments must terminate TLS before the server and restrict origin ingress; bearer-only requests avoid a cookie-based CSRF boundary. There is still no tenant isolation or per-workflow authorization policy, so one server is a single-tenant operator plane.

On this page