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.
- package/README.md +399 -6
- package/dist/cli/login.d.ts +129 -0
- package/dist/cli/login.js +596 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/workflow.d.ts +31 -0
- package/dist/cli/workflow.js +157 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +173 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +26 -5
- package/dist/client.js +93 -48
- package/dist/client.js.map +1 -1
- package/dist/credentials.d.ts +123 -0
- package/dist/credentials.js +317 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/remote/app.d.ts +18 -0
- package/dist/remote/app.js +77 -0
- package/dist/remote/app.js.map +1 -0
- package/dist/remote/documents.d.ts +175 -0
- package/dist/remote/documents.js +122 -0
- package/dist/remote/documents.js.map +1 -0
- package/dist/remote/values.d.ts +67 -0
- package/dist/remote/values.js +156 -0
- package/dist/remote/values.js.map +1 -0
- package/dist/remote/world.d.ts +50 -0
- package/dist/remote/world.js +628 -0
- package/dist/remote/world.js.map +1 -0
- package/dist/unfazed.d.ts +1 -1
- package/dist/unfazed.js +1 -1
- package/dist/unfazed.js.map +1 -1
- package/dist/workflow/config.d.ts +23 -0
- package/dist/workflow/config.js +48 -0
- package/dist/workflow/config.js.map +1 -0
- package/dist/workflow/errors.d.ts +33 -0
- package/dist/workflow/errors.js +16 -0
- package/dist/workflow/errors.js.map +1 -0
- package/dist/workflow/index.d.ts +30 -0
- package/dist/workflow/index.js +23 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/inspect-render.d.ts +93 -0
- package/dist/workflow/inspect-render.js +302 -0
- package/dist/workflow/inspect-render.js.map +1 -0
- package/dist/workflow/inspect.d.ts +52 -0
- package/dist/workflow/inspect.js +879 -0
- package/dist/workflow/inspect.js.map +1 -0
- package/dist/workflow/migration-files.d.ts +13 -0
- package/dist/workflow/migration-files.js +52 -0
- package/dist/workflow/migration-files.js.map +1 -0
- package/dist/workflow/migration-types.d.ts +19 -0
- package/dist/workflow/migration-types.js +8 -0
- package/dist/workflow/migration-types.js.map +1 -0
- package/dist/workflow/migrations.d.ts +12 -0
- package/dist/workflow/migrations.generated.d.ts +2 -0
- package/dist/workflow/migrations.generated.js +34 -0
- package/dist/workflow/migrations.generated.js.map +1 -0
- package/dist/workflow/migrations.js +94 -0
- package/dist/workflow/migrations.js.map +1 -0
- package/dist/workflow/nitro-flow-guard.d.ts +4 -0
- package/dist/workflow/nitro-flow-guard.js +19 -0
- package/dist/workflow/nitro-flow-guard.js.map +1 -0
- package/dist/workflow/nitro-inspect.d.ts +3 -0
- package/dist/workflow/nitro-inspect.js +19 -0
- package/dist/workflow/nitro-inspect.js.map +1 -0
- package/dist/workflow/nitro-plugin.d.ts +1 -0
- package/dist/workflow/nitro-plugin.js +13 -0
- package/dist/workflow/nitro-plugin.js.map +1 -0
- package/dist/workflow/nitro.d.ts +70 -0
- package/dist/workflow/nitro.js +213 -0
- package/dist/workflow/nitro.js.map +1 -0
- package/dist/workflow/public-url.d.ts +27 -0
- package/dist/workflow/public-url.js +45 -0
- package/dist/workflow/public-url.js.map +1 -0
- package/dist/workflow/queue.d.ts +128 -0
- package/dist/workflow/queue.js +375 -0
- package/dist/workflow/queue.js.map +1 -0
- package/dist/workflow/state.d.ts +25 -0
- package/dist/workflow/state.js +15 -0
- package/dist/workflow/state.js.map +1 -0
- package/dist/workflow/transport.d.ts +12 -0
- package/dist/workflow/transport.js +29 -0
- package/dist/workflow/transport.js.map +1 -0
- package/dist/workflow/world.d.ts +123 -0
- package/dist/workflow/world.js +375 -0
- package/dist/workflow/world.js.map +1 -0
- 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
|
-
|
|
33
|
-
|
|
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();
|
|
41
|
-
const unfazed = new Unfazed({ token });
|
|
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
|
|
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>;
|