unfazed 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +399 -6
  2. package/dist/cli/login.d.ts +129 -0
  3. package/dist/cli/login.js +596 -0
  4. package/dist/cli/login.js.map +1 -0
  5. package/dist/cli/workflow.d.ts +31 -0
  6. package/dist/cli/workflow.js +157 -0
  7. package/dist/cli/workflow.js.map +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +173 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/client.d.ts +26 -5
  12. package/dist/client.js +93 -48
  13. package/dist/client.js.map +1 -1
  14. package/dist/credentials.d.ts +123 -0
  15. package/dist/credentials.js +317 -0
  16. package/dist/credentials.js.map +1 -0
  17. package/dist/errors.d.ts +25 -0
  18. package/dist/errors.js +25 -0
  19. package/dist/errors.js.map +1 -1
  20. package/dist/index.d.ts +32 -1
  21. package/dist/index.js +32 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/remote/app.d.ts +18 -0
  24. package/dist/remote/app.js +77 -0
  25. package/dist/remote/app.js.map +1 -0
  26. package/dist/remote/documents.d.ts +175 -0
  27. package/dist/remote/documents.js +122 -0
  28. package/dist/remote/documents.js.map +1 -0
  29. package/dist/remote/values.d.ts +67 -0
  30. package/dist/remote/values.js +156 -0
  31. package/dist/remote/values.js.map +1 -0
  32. package/dist/remote/world.d.ts +50 -0
  33. package/dist/remote/world.js +628 -0
  34. package/dist/remote/world.js.map +1 -0
  35. package/dist/unfazed.d.ts +1 -1
  36. package/dist/unfazed.js +1 -1
  37. package/dist/unfazed.js.map +1 -1
  38. package/dist/workflow/config.d.ts +23 -0
  39. package/dist/workflow/config.js +48 -0
  40. package/dist/workflow/config.js.map +1 -0
  41. package/dist/workflow/errors.d.ts +33 -0
  42. package/dist/workflow/errors.js +16 -0
  43. package/dist/workflow/errors.js.map +1 -0
  44. package/dist/workflow/index.d.ts +30 -0
  45. package/dist/workflow/index.js +23 -0
  46. package/dist/workflow/index.js.map +1 -0
  47. package/dist/workflow/inspect-render.d.ts +93 -0
  48. package/dist/workflow/inspect-render.js +302 -0
  49. package/dist/workflow/inspect-render.js.map +1 -0
  50. package/dist/workflow/inspect.d.ts +52 -0
  51. package/dist/workflow/inspect.js +879 -0
  52. package/dist/workflow/inspect.js.map +1 -0
  53. package/dist/workflow/migration-files.d.ts +13 -0
  54. package/dist/workflow/migration-files.js +52 -0
  55. package/dist/workflow/migration-files.js.map +1 -0
  56. package/dist/workflow/migration-types.d.ts +19 -0
  57. package/dist/workflow/migration-types.js +8 -0
  58. package/dist/workflow/migration-types.js.map +1 -0
  59. package/dist/workflow/migrations.d.ts +12 -0
  60. package/dist/workflow/migrations.generated.d.ts +2 -0
  61. package/dist/workflow/migrations.generated.js +34 -0
  62. package/dist/workflow/migrations.generated.js.map +1 -0
  63. package/dist/workflow/migrations.js +94 -0
  64. package/dist/workflow/migrations.js.map +1 -0
  65. package/dist/workflow/nitro-flow-guard.d.ts +4 -0
  66. package/dist/workflow/nitro-flow-guard.js +19 -0
  67. package/dist/workflow/nitro-flow-guard.js.map +1 -0
  68. package/dist/workflow/nitro-inspect.d.ts +3 -0
  69. package/dist/workflow/nitro-inspect.js +19 -0
  70. package/dist/workflow/nitro-inspect.js.map +1 -0
  71. package/dist/workflow/nitro-plugin.d.ts +1 -0
  72. package/dist/workflow/nitro-plugin.js +13 -0
  73. package/dist/workflow/nitro-plugin.js.map +1 -0
  74. package/dist/workflow/nitro.d.ts +70 -0
  75. package/dist/workflow/nitro.js +213 -0
  76. package/dist/workflow/nitro.js.map +1 -0
  77. package/dist/workflow/public-url.d.ts +27 -0
  78. package/dist/workflow/public-url.js +45 -0
  79. package/dist/workflow/public-url.js.map +1 -0
  80. package/dist/workflow/queue.d.ts +128 -0
  81. package/dist/workflow/queue.js +375 -0
  82. package/dist/workflow/queue.js.map +1 -0
  83. package/dist/workflow/state.d.ts +25 -0
  84. package/dist/workflow/state.js +15 -0
  85. package/dist/workflow/state.js.map +1 -0
  86. package/dist/workflow/transport.d.ts +12 -0
  87. package/dist/workflow/transport.js +29 -0
  88. package/dist/workflow/transport.js.map +1 -0
  89. package/dist/workflow/world.d.ts +123 -0
  90. package/dist/workflow/world.js +375 -0
  91. package/dist/workflow/world.js.map +1 -0
  92. package/package.json +79 -6
package/README.md CHANGED
@@ -18,6 +18,13 @@ SDK: one script. Three requests. You read the four lines you cared about.
18
18
  ```
19
19
 
20
20
  - Zero runtime dependencies. Installing `unfazed` installs exactly one package.
21
+ (The optional [`unfazed/workflow`](#durable-workflows-unfazedworkflow) subpath
22
+ uses Workflow SDK packages your app installs; the root entry never loads them.)
23
+ - `npx unfazed login` signs you in from a terminal in the browser; the SDK then
24
+ acts as you, with no token to copy ([details](#sign-in-from-a-terminal-npx-unfazed)).
25
+ - `npx unfazed workflow inspect runs --app <alias>`: Workflow SDK's CLI and web
26
+ UI read your app's runs through the platform, from any directory
27
+ ([details](#inspect-runs-from-your-terminal)).
21
28
  - Strict TypeScript, generated from the platform's own GraphQL schema.
22
29
  - Bun-first, runs unchanged on Node >= 20 and Deno.
23
30
 
@@ -29,27 +36,79 @@ bun add unfazed # or: npm i unfazed
29
36
 
30
37
  ## Auth
31
38
 
32
- Get a token from <https://unfazed.dev/auth.md> (or `POST
33
- https://unfazed.dev/agents/register`). The SDK reads `UNFZ_API_TOKEN`:
39
+ **A person** signs in once from a terminal, in the browser
40
+ ([`npx unfazed login`](#sign-in-from-a-terminal-npx-unfazed)); the SDK then acts
41
+ as them, with their apps, teams and claimed agents:
42
+
43
+ ```sh
44
+ npx unfazed login
45
+ ```
46
+
47
+ **An agent or a CI job** uses a bearer token instead: an agent registers itself
48
+ at <https://unfazed.dev/auth.md> (`POST https://unfazed.dev/agents/register`).
49
+ The SDK reads `UNFZ_API_TOKEN`, which wins over a stored login:
34
50
 
35
51
  ```sh
36
52
  export UNFZ_API_TOKEN="..."
37
53
  ```
38
54
 
39
55
  ```ts
40
- const unfazed = new Unfazed(); // reads UNFZ_API_TOKEN
41
- const unfazed = new Unfazed({ token }); // or pass it explicitly
56
+ const unfazed = new Unfazed(); // UNFZ_API_TOKEN, else your `npx unfazed login`
57
+ const unfazed = new Unfazed({ token }); // or pass a token explicitly
42
58
  ```
43
59
 
44
60
  Both endpoint and token are configurable. `UNFZ_API_URL` overrides the endpoint
45
- (default `https://unfazed.dev/query`); `baseUrl` is a convenience for
46
- self-hosted control planes where only the host differs.
61
+ (default `https://unfazed.dev/query`, or the stored login's); `baseUrl` is a
62
+ convenience for self-hosted control planes where only the host differs.
47
63
 
48
64
  ```ts
49
65
  new Unfazed({ token, baseUrl: "https://staging.example.com" }); // → /query
50
66
  new Unfazed({ token, endpoint: "https://example.com/graphql" }); // exact
51
67
  ```
52
68
 
69
+ ## Sign in from a terminal: `npx unfazed`
70
+
71
+ The package's `unfazed` bin signs a person in, the way `vercel login` does. It
72
+ runs from any directory (`npx` fetches the package when the directory has no
73
+ `unfazed`), on Node >= 20.16 or Bun, with no dependencies.
74
+
75
+ ```sh
76
+ npx unfazed login # opens the browser; you sign in and allow access
77
+ npx unfazed whoami # who the SDK acts as here
78
+ npx unfazed logout # revokes the login where the server offers it, and deletes it
79
+ ```
80
+
81
+ ```
82
+ $ npx unfazed login
83
+ Opening your browser to sign in to https://unfazed.dev.
84
+ …
85
+ Signed in to https://unfazed.dev as ada (user).
86
+ The login is stored in /home/ada/.config/unfazed/credentials.json.
87
+ ```
88
+
89
+ - **How:** OAuth 2.1 with PKCE against the platform's own authorization server,
90
+ the one MCP clients sign in through. The CLI registers itself once as a
91
+ public client (cached in `oauth-clients.json` beside the login), listens on
92
+ `http://127.0.0.1:<random port>/callback` for the answer, and asks for
93
+ `unfz:read unfz:deploy` (`--scope` narrows it).
94
+ - **The login** is `${XDG_CONFIG_HOME:-~/.config}/unfazed/credentials.json`,
95
+ mode 0600: the access token (an hour long), the refresh token (30 days, rotated
96
+ on every refresh), the expiry, the endpoint and who signed in. The SDK uses it
97
+ whenever no token is passed and `UNFZ_API_TOKEN` is unset, refreshes the access
98
+ token before it expires (and once when the platform refuses it), and writes
99
+ the rotated pair back for the next process. It is only ever sent to the
100
+ endpoint it was issued by; another `UNFZ_API_URL` needs its own
101
+ `UNFZ_API_URL=… npx unfazed login`. `new UnfazedClient({ login: false })`
102
+ ignores it.
103
+ - **A browser on another machine** (an SSH session, a box): the URL is printed
104
+ instead of opened. Sign in there, then paste the address the browser ends up
105
+ on (`http://127.0.0.1:…/callback?code=…`, which does not load there) into the
106
+ terminal. `--no-browser` prints the URL on your own machine too.
107
+ - **Another control plane:** `--endpoint https://staging.example.com` (or
108
+ `UNFZ_API_URL`).
109
+ - `whoami --json` prints the same as JSON; nothing the CLI prints ever carries
110
+ a token.
111
+
53
112
  ## Code mode: deploy, wait, tail logs, in one script
54
113
 
55
114
  ```ts
@@ -413,6 +472,336 @@ So an agent working inside a run can create workspaces, deploy, and read logs
413
472
  through this same SDK with zero configuration and no credential ever written
414
473
  into the prompt.
415
474
 
475
+ ## Durable workflows: `unfazed/workflow`
476
+
477
+ [Workflow SDK](https://workflow-sdk.dev) runs durable functions: a
478
+ `"use workflow"` function orchestrates `"use step"` functions, survives
479
+ crashes and redeploys, sleeps for minutes or months and waits for webhooks.
480
+ `unfazed/workflow` is the **World** that runs it on unfazed:
481
+
482
+ - run storage, streams and `await run.returnValue` are the upstream
483
+ `@workflow/world-postgres` code over **the app's own Postgres** (its
484
+ migrations are applied for you; the upstream graphile-worker queue is never
485
+ started);
486
+ - the **queue is the platform's**: every wake-up (`sleep()`, a retry, the next
487
+ step, a resumed hook) is a delivery the unfazed worker makes to the app's flow
488
+ route, cold-starting an `on_demand` VM when it is asleep. Nothing needs a warm
489
+ process, so the default `on_demand` lifecycle is the right one.
490
+
491
+ ```sh
492
+ bun add unfazed workflow @workflow/world-postgres nitro hono
493
+ ```
494
+
495
+ `workflow`, `@workflow/core`, `@workflow/world`, `@workflow/world-postgres`,
496
+ `pg`, `drizzle-orm` and `nitro` are **optional peer dependencies**: code-mode
497
+ users of `unfazed` install none of them. `@workflow/core` arrives with
498
+ `workflow`, and `@workflow/world`, `pg` and `drizzle-orm` with
499
+ `@workflow/world-postgres`, under npm and Bun; with pnpm or Yarn PnP add them
500
+ to the app as well.
501
+
502
+ ### With Nitro (the recipe)
503
+
504
+ ```ts
505
+ // nitro.config.ts
506
+ import { defineConfig } from "nitro";
507
+
508
+ export default defineConfig({
509
+ modules: ["workflow/nitro", "unfazed/workflow/nitro"],
510
+ routes: { "/**": "./src/app.ts" }, // a Hono app; not named server.ts (Nitro claims that name)
511
+ });
512
+ ```
513
+
514
+ ```json
515
+ // unfazed.json — package.json has "build": "nitro build"
516
+ { "kind": "SERVICE", "runtime": "BUN", "install": "bun install", "build": "bun run build", "entry": "unfazed-entry.ts" }
517
+ ```
518
+
519
+ ```ts
520
+ // unfazed-entry.ts
521
+ export { default } from "./.output/server/index.mjs";
522
+ ```
523
+
524
+ Then bind a Postgres or Neon integration to the app and deploy. In a
525
+ production build the module:
526
+
527
+ - installs the World with `setWorld(createWorld())` from a server plugin, which
528
+ Nitro runs before the first request (a bundle without `node_modules` cannot
529
+ load a World through `WORKFLOW_TARGET_WORLD`);
530
+ - embeds the SQL migrations of **the app's own** `@workflow/world-postgres`, so
531
+ the schema always matches the storage code in the bundle;
532
+ - answers anything that reaches `/.well-known/workflow/v1/flow` without the
533
+ platform's queue token with **401** (including the SDK's `?__health` probe).
534
+ Public ingress strips that header, so only the platform can deliver. The
535
+ webhook route stays public: its unguessable token is its authorization;
536
+ - mounts the **inspection route** the unfazed dashboard and MCP read runs
537
+ through (below), with the build's workflow list and Workflow SDK version;
538
+ - sets `noExternals: true` and builds Nitro's `standard` output (`export
539
+ default { fetch }`), which is what unfazed serves, whenever no preset was
540
+ chosen, whatever Nitro detected on the build machine: `bun` under Bun (as on
541
+ the unfazed build VM), `node-server` under Node, `deno-server` under Deno.
542
+ Their own outputs start a server of their own (under Bun with a 10 s idle
543
+ timeout that cuts every quiet workflow stream). A preset chosen explicitly
544
+ (`preset` in nitro.config.ts or .nitrorc, `NITRO_PRESET`, `SERVER_PRESET`)
545
+ other than `standard` **fails the build** with a message saying so; leave it
546
+ out, or set `preset: "standard"`.
547
+ - serves public assets only when `serveStatic: "inline"` embeds them in the
548
+ bundle (a service VM has nothing next to it to read from disk). That is how
549
+ one service ships an API and its UI (`publicAssets: [{ dir: "dist" }]`
550
+ after a `vite build`).
551
+
552
+ `nitro dev` is left alone and keeps the SDK's Local World.
553
+
554
+ A complete app — steps, a retried step, `sleep`, a webhook (plain
555
+ `createWebhook()` and `webhook.url`), a progress stream — is in
556
+ [`examples/workflow-nitro`](./examples/workflow-nitro). Once it is deployed,
557
+ `npx unfazed workflow inspect runs --app <alias>` lists its runs
558
+ ([Inspect runs from your terminal](#inspect-runs-from-your-terminal)).
559
+ [`examples/birthday-card-generator`](./examples/birthday-card-generator) is
560
+ Vercel's Workflow launch demo on unfazed: a React UI built by Vite and served
561
+ from the same bundle, AI steps, webhooks resumed from emails, `sleep` until a
562
+ date, and a stream the UI reconnects to; it runs without any provider keys.
563
+
564
+ ### Without Nitro
565
+
566
+ Install the World in your framework's start-up hook, before it serves:
567
+
568
+ ```ts
569
+ import { setWorld } from "workflow/runtime";
570
+ import { createWorld } from "unfazed/workflow";
571
+
572
+ setWorld(createWorld()); // Next.js: instrumentation.ts register(); SvelteKit: hooks.server.ts init
573
+ ```
574
+
575
+ `createWorld()` is synchronous and opens nothing. Without the Nitro module the
576
+ World applies the migrations embedded in this package (built from the
577
+ `@workflow/world-postgres` version in its devDependencies), so keep the app's
578
+ `@workflow/world-postgres` on that version.
579
+
580
+ Mount the inspection route yourself, as a catch-all under its base path:
581
+
582
+ ```ts
583
+ // e.g. Next.js: app/.well-known/workflow/v1/unfazed/inspect/v1/[op]/route.ts
584
+ import { createInspectHandler } from "unfazed/workflow";
585
+
586
+ const inspect = createInspectHandler({ workflowVersion: "5.2.2" }); // optional: workflows: [...names]
587
+ export const GET = inspect;
588
+ export const POST = inspect;
589
+ ```
590
+
591
+ ### Inspecting runs from the dashboard and MCP
592
+
593
+ The app answers the platform's questions about its runs itself, on
594
+ `/.well-known/workflow/v1/unfazed/inspect/v1/<op>` (unfazed
595
+ `docs/workflow-inspect.md`, guest contract v1): `info`, `runs`, `run`, `steps`,
596
+ `step`, `events`, `event` and `stream` read through the app's own World
597
+ (`getWorld()`), so an `on_demand` app cold-starts for the request and holds no
598
+ database connection afterwards; `wake` and `cancel` are Workflow SDK's own
599
+ `getRun(id).wakeUp()` and `.cancel()`.
600
+
601
+ - Only the platform can call it: anything without `X-Unfz-Queue-Token` equal to
602
+ `UNFZ_STATE_TOKEN` (compared in constant time) gets 401, and public ingress
603
+ strips that header. A World that failed to start answers 503
604
+ `world_unavailable` with its start error, which the owner sees.
605
+ - Payloads (inputs, outputs, errors, event data, stream chunks) are hydrated
606
+ with `workflow/observability` on top of the SDK's common revivers and sent as
607
+ display text, cut at the contract's caps (16 KiB per input or output, 64 KiB
608
+ per event payload, 8 KiB per chunk); every answer stays under 1 MiB, a page
609
+ coming back shorter (with the World's cursor for the rest) when it must.
610
+ - Hook tokens and webhook URLs never leave: hooks are shown by ID, kind and
611
+ state; every `token` key is dropped from event info and event data; every
612
+ occurrence of the run's hook tokens in an event's data reads `[redacted]`;
613
+ and every Workflow SDK webhook URL in any text the route returns (run and
614
+ step inputs and outputs, errors, event info and data, stream chunks,
615
+ attributes) has its token segment replaced, as
616
+ `https://app.example/.well-known/workflow/v1/webhook/[redacted]?answer=yes`,
617
+ also when percent-encoded inside another URL or written with JSON-escaped
618
+ slashes. That happens before any cap, so a cut never leaves part of a token.
619
+ Everything else the app writes is shown as written, as in its logs.
620
+ - A step's input shows as its arguments: Workflow SDK stores it as
621
+ `{ args, closureVars, thisVal }`, and the route answers `args`, or
622
+ `{ args, closureVars?, thisVal? }` with only the parts that hold something
623
+ (any other stored shape is shown as it is).
624
+ - A run page carries at most the latest 500 sleeps and 500 hooks
625
+ (`waits_truncated` / `hooks_truncated` say when there were more); a list
626
+ page may hold fewer items than `limit`, so callers page on `has_more`.
627
+
628
+ ### Inspect runs from your terminal
629
+
630
+ Workflow SDK's own CLI reads an app's runs through the platform, the way
631
+ `--backend vercel` reads a Vercel project. From any directory:
632
+
633
+ ```sh
634
+ npx unfazed login # once
635
+ npx unfazed workflow inspect runs --app birthday-card
636
+ ```
637
+
638
+ ```
639
+ runId workflowName status startedAt completedAt
640
+ ------------------------------- -------------------- --------- ----------------------- -----------------------
641
+ wrun_01KPBDAY00000000000000RN01 generateBirthdayCard running 2026-10-15 09:30:01.000
642
+ wrun_01KPBDAY00000000000000RN02 generateBirthdayCard completed 2026-10-15 09:10:01.000 2026-10-15 09:12:00.000
643
+ wrun_01KPBDAY00000000000000RN03 generateBirthdayCard failed 2026-10-15 09:05:01.000 2026-10-15 09:06:00.000
644
+ ```
645
+
646
+ ```sh
647
+ npx unfazed workflow inspect run <run-id> --app birthday-card # input, output, error
648
+ npx unfazed workflow inspect steps --runId <run-id> --app birthday-card # also: events, hooks, sleeps, streams
649
+ npx unfazed workflow inspect step <step-id> --runId <run-id> --app birthday-card
650
+ npx unfazed workflow inspect stream <stream-id> --runId <run-id> --app birthday-card # follows it while the run writes
651
+ npx unfazed workflow cancel <run-id> --app birthday-card
652
+ npx unfazed workflow web --app birthday-card # the local web UI: trace, events, streams,
653
+ # Wake Up Sleep and Cancel
654
+ ```
655
+
656
+ `npx unfazed workflow …` is Workflow SDK's CLI with this package as its
657
+ backend: everything after `workflow` but `--app` is passed to it, and its
658
+ output and exit code come back unchanged. It runs the project's own
659
+ `workflow` (major 5) when the directory has one, else `npx -y workflow@5`.
660
+
661
+ **Inside a project** that has `unfazed` installed, the CLI's native form works
662
+ too, since the CLI resolves `--backend unfazed` from the project:
663
+
664
+ ```sh
665
+ UNFZ_APP=birthday-card npx workflow inspect runs --backend unfazed
666
+ ```
667
+
668
+ `export WORKFLOW_TARGET_WORLD=unfazed` saves typing `--backend` there. The
669
+ usual options work (`--json`, `--limit`, `--cursor`, `--all`, `--status`,
670
+ `--workflowName`, `--interactive`); the exceptions are below.
671
+
672
+ - **Which app:** `--app` (`npx unfazed workflow` only), else `UNFZ_APP` (its
673
+ alias, the `<alias>` of `https://<alias>.unfz.to`), else the `alias` in
674
+ `./unfazed.json`, or its only `apps[]` entry. `UNFZ_API_URL` points at
675
+ another control plane.
676
+ - **The token** is your `npx unfazed login`, or `UNFZ_API_TOKEN` when set. It
677
+ needs `unfz:read`; cancel and Wake Up Sleep need `unfz:deploy` too, and the
678
+ caller must be allowed to restart the app (owner, team member, operator).
679
+ - **Every command is one platform call relayed to the app**, through the same
680
+ inspection route the dashboard reads; an `on_demand` app cold-starts for it,
681
+ as for a visitor.
682
+
683
+ What differs from `--backend vercel`:
684
+
685
+ - **Payloads are the inspection route's display data**: hydrated inside the
686
+ app and cut at its caps (16 KiB per run or step input and output, 64 KiB
687
+ per event payload, 8 KiB per stream chunk). A cut value is a string ending
688
+ in `… [truncated by unfazed inspection: N bytes in full]`. Values are JSON:
689
+ a `Date` reads as its ISO string, a `Map`, `Set`, bytes or `Error` as
690
+ `{"$map": …}`, `{"$set": …}`, `{"$bytes": n}`, `{"$error": …}`. A payload the
691
+ app could not read is `[unreadable payload: <why>]`. Run data on unfazed is
692
+ not encrypted, so `--decrypt` has nothing to do.
693
+ - **Hook tokens never leave the app**: hooks show `token: "[redacted]"`, and
694
+ the web UI cannot resume a hook.
695
+ - **List views carry no payloads**, so `--withData` (deprecated by the CLI for
696
+ lists) shows none; `inspect run <id>` and `inspect step <id>` do. Listed
697
+ events carry their metadata, `inspect event <id>` its payload.
698
+ - **No analytics read path**: `--attribute`, `--since`/`--until` and `inspect
699
+ attributes` need one, and the CLI says it is ignored or unavailable.
700
+ - **Runs list newest first only** (`--sort asc` is refused). A run's steps
701
+ newest first (the CLI's default) are read whole, up to 2 000, and paged
702
+ locally.
703
+ - **Hooks are found through their run**: `inspect hooks` without `--runId`,
704
+ and `inspect hook <id>`, look through the app's running and pending runs.
705
+ - **A run page reads at most 10 000 events**, the latest 500 sleeps and 500
706
+ hooks, and 50 streams (the route's limits).
707
+ - **Writes**: cancel (`workflow cancel`, bulk cancel, the web UI) and wake-up
708
+ (the web UI) go through the platform. Everything else (replay, re-enqueue,
709
+ resuming a hook, starting a run) is refused: this backend is read-only, and
710
+ an app's own World is `unfazed/workflow`. Setting
711
+ `WORKFLOW_TARGET_WORLD=unfazed` inside an app fails loudly for the same
712
+ reason.
713
+
714
+ ### How it behaves
715
+
716
+ - **Start-up is lazy and cheap.** The first World call of a process applies the
717
+ migrations (one query when the database is current) and probes the platform
718
+ queue; every call waits for that, so a request arriving during a cold start
719
+ waits instead of failing. Active runs are then re-enqueued in the background
720
+ with the key `reenqueue:<runId>:<deploymentId>`, which the platform drops as
721
+ a duplicate while that message is stored and for an hour after it settles,
722
+ so a run asleep for days is not re-enqueued on every cold start.
723
+ `world.start()` does the same and lets you await it.
724
+ - **No warm process, no lingering connections.** Postgres connections,
725
+ including the `LISTEN` ones behind stream readers and
726
+ `waitForTerminalStatus`, are held only while a World call or a stream reader
727
+ is open (plus 2 s); idle pool connections close after 5 s. A reaped VM leaves
728
+ nothing behind on the database.
729
+ - **Deliveries** carry `X-Unfz-Queue-*` headers; the handler checks the token
730
+ in constant time against `UNFZ_STATE_TOKEN`, maps the rest to the SDK's
731
+ message metadata, and reports the delivery deadline (15 minutes from the
732
+ moment the VM was ready, so a cold start does not shorten it) through
733
+ `getRuntimeDeadline()`, so the SDK sizes its inline work to fit. Every answer
734
+ is a complete JSON body and never a redirect (the platform follows none, and
735
+ retries a 3xx or a broken 2xx body).
736
+ - **Concurrency:** at most 32 deliveries per app are open at once. Set it per
737
+ app in `unfazed.json` with `"workflow": {"concurrency": N}` (1 to 256;
738
+ `workflow_concurrency` on `create_service_deployment` for an app deployed
739
+ from a workspace). A step that awaits a child run holds one delivery for the
740
+ child's whole run, so keep the concurrency above the number of such waits.
741
+ When the VM stops on its own under deliveries (out of memory, a crash), the
742
+ platform halves the app's concurrency for 5 minutes and records a
743
+ `service.workflow_throttled` event.
744
+ - **Routes for other queue consumers:** `world.registerRoute(prefix, path)` is
745
+ stored on the platform. `registerHandler()` is not offered: the platform
746
+ delivers over HTTP, never inside a process.
747
+ - **Webhook URLs need no code.** `createWebhook().url` (and
748
+ `getWorkflowMetadata().url`) are built on the app's public address. Workflow
749
+ SDK 5.x reads only `VERCEL_URL` for that (`https://${VERCEL_URL}`, else
750
+ `http://localhost:<port>`), so when `VERCEL_URL` is unset and
751
+ `UNFZ_PUBLIC_URL` is a bare https host — a custom domain or
752
+ `https://<alias>.unfz.to` — `createWorld()` sets `VERCEL_URL` to that host in
753
+ the app's own process. Inside the SDK that variable decides nothing else
754
+ (it also skips probing for the local port, which is then unneeded). Your own
755
+ code and libraries see it too, as the public host. Opt out with
756
+ `createWorld({ publicUrl: false })`; an explicit `VERCEL_URL` is never
757
+ changed.
758
+
759
+ A **path-form** public URL (`https://app.unfazed.dev/<alias>`) cannot be
760
+ passed that way: the SDK's base path is fixed at build time and Workflow
761
+ SDK's Nitro integration sets none. Those apps build the URL in a step:
762
+
763
+ ```ts
764
+ async function publicWebhookUrl(token: string) {
765
+ "use step";
766
+ return `${process.env.UNFZ_PUBLIC_URL}/.well-known/workflow/v1/webhook/${token}`;
767
+ }
768
+ ```
769
+ - **Errors** are `UnfazedWorkflowError` with a `code`: `no_database` (bind a
770
+ Postgres or Neon integration), `no_platform` (not inside an unfazed service),
771
+ `queue_not_supported` (the worker has no queue yet), `queue_unavailable`,
772
+ `quota_exceeded` (its message names the limit: 100 000 pending messages,
773
+ 64 MiB of live messages or 200 000 idempotency keys), `payload_too_large`
774
+ (over 1 MiB), `unavailable` (503 or network, after 30 s of retries with the
775
+ same idempotency keys), …
776
+
777
+ | Read from | Meaning |
778
+ | --- | --- |
779
+ | `WORKFLOW_POSTGRES_URL`, `DATABASE_URL_UNPOOLED`, `DATABASE_URL` | run storage, first one set; `sslmode=require` gets `uselibpqcompat=true` |
780
+ | `WORKFLOW_POSTGRES_MAX_POOL_SIZE` | pool size (default 10) |
781
+ | `UNFZ_STATE_URL`, `UNFZ_STATE_TOKEN` | the platform queue and the delivery token (injected by unfazed) |
782
+ | `UNFZ_DEPLOYMENT_ID` | `getDeploymentId()` |
783
+ | `UNFZ_PUBLIC_URL` | the public address webhook URLs are built on (see above) |
784
+
785
+ `createWorld({ ... })` takes the same settings as options (`connectionString`,
786
+ `stateUrl`, `stateToken`, `deploymentId`, `maxPoolSize`, `migrations`,
787
+ `publicUrl`, `retryBudgetMs`, `idleReleaseMs`, `env`, `fetch`).
788
+
789
+ ### Testing it
790
+
791
+ ```sh
792
+ bun test # unit tests, incl. a fake platform implementing the wire contract
793
+ export UNFAZED_TEST_DATABASE_URL=postgres://... # a database whose user may CREATE DATABASE
794
+ bun test test/workflow # + the World against Postgres
795
+ WORKFLOW_POSTGRES_URL=$UNFAZED_TEST_DATABASE_URL bun run test:conformance
796
+ # the SDK's World conformance suite, and every
797
+ # delivery cold-started in a fresh process
798
+ bun run test:example # the example, bundled as unfazed builds it,
799
+ # one fresh process per delivery and request
800
+ bun run test:birthday # the same for examples/birthday-card-generator
801
+ bun run test:cli # Workflow SDK's CLI with --backend unfazed (and --web)
802
+ # against a fake of the platform's inspection API
803
+ ```
804
+
416
805
  ## Development
417
806
 
418
807
  ```sh
@@ -424,6 +813,10 @@ bun run build
424
813
  bun run check # all of the above, plus a dist smoke test on Bun AND Node
425
814
  ```
426
815
 
816
+ After bumping the `@workflow/world-postgres` devDependency, run
817
+ `bun run embed-migrations`: `bun run check` fails while
818
+ `src/workflow/migrations.generated.ts` is stale.
819
+
427
820
  ### Regenerating from the schema
428
821
 
429
822
  `schema/schema.graphqls` is a **vendored copy** of `graph/schema.graphqls` from
@@ -0,0 +1,129 @@
1
+ import { type StoredCredentials } from "../credentials.js";
2
+ import { UnfazedError } from "../errors.js";
3
+ export declare const USER_AGENT = "unfazed-cli/0.6.1 (+https://unfazed.dev/auth.md)";
4
+ /** What a login asks for: reading and deploying, like an MCP client. */
5
+ export declare const DEFAULT_SCOPE = "unfz:read unfz:deploy";
6
+ export declare const CLIENT_NAME = "unfazed CLI";
7
+ export declare const CALLBACK_PATH = "/callback";
8
+ /** The redirect registered once; each login uses it with its own port. */
9
+ export declare const REGISTERED_REDIRECT_URI = "http://127.0.0.1/callback";
10
+ /** Registered clients, per issuer, beside the login. */
11
+ export declare const CLIENTS_FILE = "oauth-clients.json";
12
+ /** How long a login waits for the browser. */
13
+ export declare const LOGIN_TIMEOUT_MS: number;
14
+ export type Env = Record<string, string | undefined>;
15
+ /** The `fetch` the CLI needs; the global one fits. */
16
+ export type Fetch = (input: string, init: {
17
+ method: string;
18
+ headers: Record<string, string>;
19
+ body?: string;
20
+ redirect?: "manual" | "follow";
21
+ signal?: AbortSignal;
22
+ }) => Promise<{
23
+ ok: boolean;
24
+ status: number;
25
+ text(): Promise<string>;
26
+ }>;
27
+ /** A login that could not be completed; the message says why and what to do. */
28
+ export declare class UnfazedLoginError extends UnfazedError {
29
+ }
30
+ /** The GraphQL endpoint: given, else `UNFZ_API_URL`, else unfazed.dev's; a bare origin gets `/query`. */
31
+ export declare function resolveEndpoint(raw: string | undefined, env: Env): string;
32
+ /** Whether a URL may carry tokens: https, or plain http to this machine only. */
33
+ export declare function safeURL(raw: unknown): raw is string;
34
+ /** RFC 8414 authorization server metadata: the fields the CLI uses. */
35
+ export interface AuthServerMetadata {
36
+ issuer: string;
37
+ authorization_endpoint: string;
38
+ token_endpoint: string;
39
+ registration_endpoint?: string;
40
+ revocation_endpoint?: string;
41
+ code_challenge_methods_supported?: string[];
42
+ response_types_supported?: string[];
43
+ authorization_response_iss_parameter_supported?: boolean;
44
+ }
45
+ /** Reads and checks the metadata of the server at `origin` (e.g. https://unfazed.dev). */
46
+ export declare function discover(origin: string, fetch: Fetch): Promise<AuthServerMetadata>;
47
+ /** A PKCE verifier and its S256 challenge. */
48
+ export declare function pkcePair(): {
49
+ verifier: string;
50
+ challenge: string;
51
+ };
52
+ /** A client the CLI registered at one issuer. */
53
+ export interface ClientRecord {
54
+ client_id: string;
55
+ redirect_uri: string;
56
+ scope: string;
57
+ registered_at: string;
58
+ }
59
+ /** The cached client for `issuer` that may ask for `scope`, if any. */
60
+ export declare function cachedClient(env: Env, issuer: string, scope: string): ClientRecord | undefined;
61
+ export interface LoginOptions {
62
+ /** GraphQL endpoint or platform origin. Default: `UNFZ_API_URL`, else https://unfazed.dev. */
63
+ endpoint?: string;
64
+ /** Space-separated scopes. Default: `unfz:read unfz:deploy`. */
65
+ scope?: string;
66
+ /** Default `process.env`: where `UNFZ_API_URL` and the login's location come from. */
67
+ env?: Env;
68
+ fetch?: Fetch;
69
+ /** Opens the sign-in URL; resolves to whether a browser started. Default: print only. */
70
+ openBrowser?: (url: string) => boolean | Promise<boolean>;
71
+ /** Where progress goes (never a token). Default: stderr. */
72
+ log?: (line: string) => void;
73
+ /** Where a pasted answer is read from (a browser on another machine). Default: none. */
74
+ input?: NodeJS.ReadableStream;
75
+ /** How long to wait for the browser. Default {@link LOGIN_TIMEOUT_MS}. */
76
+ timeoutMs?: number;
77
+ }
78
+ /** Signs in through the browser and stores the login. Resolves to it and where it was stored. */
79
+ export declare function login(options?: LoginOptions): Promise<{
80
+ credentials: StoredCredentials;
81
+ path: string;
82
+ }>;
83
+ /** The fields `whoami` shows. */
84
+ export interface Actor {
85
+ kind: string;
86
+ userID: string;
87
+ name: string;
88
+ scopes: string[];
89
+ }
90
+ export interface WhoamiResult {
91
+ actor: Actor;
92
+ endpoint: string;
93
+ /** `token`: UNFZ_API_TOKEN; `login`: the stored login at `path`. */
94
+ source: "token" | "login";
95
+ path?: string;
96
+ }
97
+ /** Who the SDK acts as here: `UNFZ_API_TOKEN` when set, else the stored login (refreshed as needed). */
98
+ export declare function whoami(options?: {
99
+ env?: Env;
100
+ fetch?: Fetch;
101
+ }): Promise<WhoamiResult>;
102
+ export interface LogoutResult {
103
+ /** Whether there was a login to remove. */
104
+ signedOut: boolean;
105
+ issuer?: string;
106
+ /** Whether the server revoked the tokens. */
107
+ revoked: boolean;
108
+ /** Whether the server offers revocation (RFC 7009). */
109
+ revocationSupported: boolean;
110
+ }
111
+ /**
112
+ * Revokes the stored tokens when the server advertises revocation, then
113
+ * deletes the login. The endpoint is read from the server's metadata again,
114
+ * so one the server gained since the login is used.
115
+ */
116
+ export declare function logout(options?: {
117
+ env?: Env;
118
+ fetch?: Fetch;
119
+ }): Promise<LogoutResult>;
120
+ export declare function accountName(credentials: StoredCredentials): string;
121
+ /**
122
+ * Whether a browser can be expected here. Over SSH, or on Linux without a
123
+ * display, the login prints the URL instead (and takes the pasted answer).
124
+ */
125
+ export declare function canOpenBrowser(env: Env, platform?: NodeJS.Platform): boolean;
126
+ /** The command that opens `url` — `$BROWSER`, `open`, `xdg-open` or the Windows URL handler — as argv, never a shell line. */
127
+ export declare function browserCommand(url: string, env: Env, platform?: NodeJS.Platform): string[];
128
+ /** Starts the browser on `url`. Resolves to whether it could be started. */
129
+ export declare function openBrowser(url: string, env?: Env, platform?: NodeJS.Platform): Promise<boolean>;