anbaric 1.23.2 → 1.25.0
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 +31 -0
- package/docs/README.md +2 -0
- package/docs/api/cli.md +14 -3
- package/docs/api/environment.md +1 -0
- package/docs/api/state-machine.md +72 -4
- package/docs/features/scheduled-runs.md +106 -0
- package/docs/features/state-machines.md +43 -0
- package/docs/patterns/ux-practices.md +62 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -266,6 +266,37 @@ re-evaluates its transitions, so the input drives it on and the wait clears once
|
|
|
266
266
|
it moves to another state. Actions placed after an `Await` in the same state do
|
|
267
267
|
not run when the job resumes.
|
|
268
268
|
|
|
269
|
+
Some work is started by the clock rather than by a person or an event. The
|
|
270
|
+
**`JobRunScheduler`** starts jobs on a timetable — give it a machine and a
|
|
271
|
+
`Schedule` and it does the rest:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import {JobRunScheduler, Schedule} from "anbaric";
|
|
275
|
+
|
|
276
|
+
// 02:00 every day
|
|
277
|
+
JobRunScheduler.instance().schedule(
|
|
278
|
+
reconciliation,
|
|
279
|
+
new Schedule(Schedule.everyDay(), [{ hours: 2, minutes: 0 }]),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// weekdays at 09:00 and 17:30
|
|
283
|
+
JobRunScheduler.instance().schedule(
|
|
284
|
+
digest,
|
|
285
|
+
new Schedule(Schedule.daysOfWeek([1, 2, 3, 4, 5]), [{ hours: 9, minutes: 0 }, { hours: 17, minutes: 30 }]),
|
|
286
|
+
);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The day test is a predicate, so anything expressible in code is a schedule.
|
|
290
|
+
Runs are **planned ahead and stored** rather than discovered at the last moment,
|
|
291
|
+
so a restart keeps the timetable and a scheduler that was down starts the runs
|
|
292
|
+
it missed instead of skipping them; each job carries the run it belongs to in
|
|
293
|
+
`scheduledFor`. Machines due at the same moment are spread by a small random
|
|
294
|
+
offset, applied when the run is planned so the stored time is the real one.
|
|
295
|
+
Locally the plan is in memory; deployed it lives in the platform database, where
|
|
296
|
+
claiming a due run is atomic — so several instances of your app can run the
|
|
297
|
+
scheduler and each run still starts exactly once. See
|
|
298
|
+
[Scheduled runs](anbaric/docs/features/scheduled-runs.md).
|
|
299
|
+
|
|
269
300
|
Everything is pluggable through env-driven factories: locally (no env vars)
|
|
270
301
|
you get in-memory persistence and queueing; deployed, the same factories talk
|
|
271
302
|
to the platform automatically. The same applies to `JsonStoreFactory`
|
package/docs/README.md
CHANGED
|
@@ -21,6 +21,7 @@ What the framework gives you, one capability at a time.
|
|
|
21
21
|
- [State machines](features/state-machines.md) — defining and running a workflow
|
|
22
22
|
- [Actions and actors](features/actions-and-actors.md) — who does the work, and how
|
|
23
23
|
- [Awaiting input](features/awaiting-input.md) — pausing a job for a human or external system
|
|
24
|
+
- [Scheduled runs](features/scheduled-runs.md) — starting jobs on a timetable
|
|
24
25
|
- [AI agents](features/ai-agents.md) — letting a model drive a state
|
|
25
26
|
- [Documents and secrets](features/documents-and-secrets.md) — the JSON and secret stores
|
|
26
27
|
- [The SQL store](features/sql-store.md) — a relational database for structured data
|
|
@@ -39,6 +40,7 @@ How to put the features together to build something real.
|
|
|
39
40
|
- [Human-in-the-loop](patterns/human-in-the-loop.md) — approvals, forms and hand-offs
|
|
40
41
|
- [Integrating external systems](patterns/integrating-external-systems.md) — waiting on callbacks and webhooks
|
|
41
42
|
- [Authorization with actors and roles](patterns/authorization.md) — who is allowed to do what
|
|
43
|
+
- [UX practices](patterns/ux-practices.md) — feedback and polling for an asynchronous UI
|
|
42
44
|
- [Testing your app](patterns/testing.md) — driving a machine in memory
|
|
43
45
|
|
|
44
46
|
## API reference
|
package/docs/api/cli.md
CHANGED
|
@@ -25,9 +25,20 @@ project — they walk up to the nearest `package.json`.
|
|
|
25
25
|
| `anbaric app configure` | Create or update `.anbaric/app-config.json` (`name`, `internalPort`). |
|
|
26
26
|
| `anbaric app deploy` | Deploy the app and wait until it is live (prompts before replacing a running one). |
|
|
27
27
|
| `anbaric app update` | Deploy, replacing a running app **without** prompting. |
|
|
28
|
-
| `anbaric app status
|
|
29
|
-
| `anbaric app tail
|
|
30
|
-
| `anbaric app tear-down
|
|
28
|
+
| `anbaric app status [name]` | Show deploy state, liveness, and recent logs. |
|
|
29
|
+
| `anbaric app tail [name]` | Stream the app's runtime logs. |
|
|
30
|
+
| `anbaric app tear-down [name]` | Stop and remove the app (prompts unless `--yes`). |
|
|
31
|
+
|
|
32
|
+
The name is optional, because an `app` command is normally about the app you
|
|
33
|
+
are standing in: the CLI walks up from the working directory to the nearest
|
|
34
|
+
`package.json` and takes the name from that project's
|
|
35
|
+
`.anbaric/app-config.json`, falling back to the package's own `name`. Pass a
|
|
36
|
+
name to act on a different app, or to work from outside a project entirely.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd ~/work/crm && anbaric app tail # the app you are in
|
|
40
|
+
anbaric app status link-media-brief # a different app, from anywhere
|
|
41
|
+
```
|
|
31
42
|
|
|
32
43
|
## State machines and jobs
|
|
33
44
|
|
package/docs/api/environment.md
CHANGED
|
@@ -29,6 +29,7 @@ defaulting to a local implementation otherwise).
|
|
|
29
29
|
| --- | --- | --- |
|
|
30
30
|
| `ANBARIC_JOB_PERSISTENCE_TYPE` | where jobs are stored | in-memory |
|
|
31
31
|
| `ANBARIC_QUEUE_TYPE` | the job queue | in-memory |
|
|
32
|
+
| `ANBARIC_JOB_RUN_SCHEDULE_PERSISTENCE_TYPE` | where planned [scheduled runs](../features/scheduled-runs.md) are kept | in-memory |
|
|
32
33
|
| `ANBARIC_AUDITOR_TYPE` | the audit sink (`cloud` → platform) | console |
|
|
33
34
|
| `ANBARIC_JSON_STORE_TYPE` | the JSON document store | in-memory |
|
|
34
35
|
| `ANBARIC_SECRET_STORE_TYPE` | the secret store | in-memory (encrypted) |
|
|
@@ -217,15 +217,20 @@ transition whose predicate holds moves the job to `to`.
|
|
|
217
217
|
|
|
218
218
|
```ts
|
|
219
219
|
to : string
|
|
220
|
-
predicate : (job : Job) => boolean
|
|
220
|
+
predicate : (job : Job) => boolean // default: () => true
|
|
221
221
|
|
|
222
|
-
constructor(to : string, predicate : (job : Job) => boolean)
|
|
222
|
+
constructor(to : string, predicate? : (job : Job) => boolean)
|
|
223
223
|
```
|
|
224
224
|
|
|
225
225
|
```ts
|
|
226
226
|
new Transition("active", (job) => job.properties.get("welcomeSent") === true)
|
|
227
|
+
new Transition("scoring") // unguarded: the actions run, then the job moves on
|
|
227
228
|
```
|
|
228
229
|
|
|
230
|
+
The predicate is optional. Omit it when a state's actions simply run and the job
|
|
231
|
+
should move on, rather than inventing a sentinel property for the transition to
|
|
232
|
+
read. Guard a transition only when the move is conditional.
|
|
233
|
+
|
|
229
234
|
---
|
|
230
235
|
|
|
231
236
|
## `Job`
|
|
@@ -243,18 +248,26 @@ readonly startedAt : Date
|
|
|
243
248
|
readonly startedBy : string
|
|
244
249
|
readonly lastUpdated : Date
|
|
245
250
|
readonly killed : boolean
|
|
246
|
-
status : string // "active"
|
|
251
|
+
status : string // "active", "Awaiting input" or "Failed"
|
|
247
252
|
waitingFor? : string
|
|
248
253
|
awaitMetadata? : WaitForInput // present while parked on an Await
|
|
249
254
|
|
|
250
255
|
namespace Job {
|
|
251
|
-
const Status = {
|
|
256
|
+
const Status = {
|
|
257
|
+
ACTIVE: "active",
|
|
258
|
+
AWAITING_INPUT: "Awaiting input",
|
|
259
|
+
FAILED: "Failed",
|
|
260
|
+
} as const
|
|
252
261
|
}
|
|
253
262
|
```
|
|
254
263
|
|
|
255
264
|
Check whether a job is parked with `job.status === Job.Status.AWAITING_INPUT`;
|
|
256
265
|
read what it's waiting for from `job.awaitMetadata`.
|
|
257
266
|
|
|
267
|
+
A job whose action threw is `FAILED`, with the reason in its audit trail. It
|
|
268
|
+
stays in its state rather than transitioning, and an update that moves it on
|
|
269
|
+
returns it to `ACTIVE` — so a failure is recoverable, not terminal.
|
|
270
|
+
|
|
258
271
|
---
|
|
259
272
|
|
|
260
273
|
## `PropertyDefinition`
|
|
@@ -265,6 +278,7 @@ have a definition, or writes to it are rejected.
|
|
|
265
278
|
```ts
|
|
266
279
|
id : string
|
|
267
280
|
required : boolean = false
|
|
281
|
+
example? : any // a realistic value, for tooling
|
|
268
282
|
validation : (value : any) => boolean // default: () => true
|
|
269
283
|
|
|
270
284
|
constructor(id : string)
|
|
@@ -275,11 +289,65 @@ Configure by mutation:
|
|
|
275
289
|
```ts
|
|
276
290
|
const email = new PropertyDefinition("email");
|
|
277
291
|
email.required = true;
|
|
292
|
+
email.example = "someone@example.com";
|
|
278
293
|
email.validation = (value) => typeof value === "string" && value.includes("@");
|
|
279
294
|
```
|
|
280
295
|
|
|
281
296
|
Pass the definitions as the `StateMachine`'s fourth argument.
|
|
282
297
|
|
|
298
|
+
`example` is carried into the machine's published definition, so tools that
|
|
299
|
+
start a job — the admin console's **Start** dialog, generated documentation —
|
|
300
|
+
can offer a realistic value instead of an empty box. It is never validated and
|
|
301
|
+
never becomes a default; it is purely descriptive.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## `Schedule`
|
|
306
|
+
|
|
307
|
+
Which days a machine runs on, and at what times on those days. See
|
|
308
|
+
[Scheduled runs](../features/scheduled-runs.md).
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
constructor(dayTest : (date : Date) => boolean, times : Array<{ hours : number, minutes : number }>)
|
|
312
|
+
|
|
313
|
+
getRuns(from : Date, to : Date) : Array<Date> // exclusive of `from`, inclusive of `to`
|
|
314
|
+
|
|
315
|
+
static everyDay() : (date : Date) => boolean
|
|
316
|
+
static daysOfWeek(days : Array<number>) // 0 = Sunday … 6 = Saturday
|
|
317
|
+
static daysOfMonth(days : Array<number>) // calendar dates
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The day test is an ordinary predicate, so any rule you can write in code — the
|
|
321
|
+
last working day of a quarter, every other Tuesday — is a schedule.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## `JobRunScheduler`
|
|
326
|
+
|
|
327
|
+
Starts jobs on a timetable. Runs are planned ahead and stored, so the plan
|
|
328
|
+
survives a restart and missed runs are caught up rather than skipped.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
static instance() : JobRunScheduler
|
|
332
|
+
|
|
333
|
+
schedule(machine : StateMachine, at : Schedule,
|
|
334
|
+
lookaheadMs : number = 86_400_000,
|
|
335
|
+
randomRunOffsetMs : [number, number] = [0, 120_000]) : void
|
|
336
|
+
|
|
337
|
+
tick(now? : Date) : Promise<void> // plan and start everything owed; mostly for tests
|
|
338
|
+
cleanUp() : Promise<void>
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`lookaheadMs` is how far ahead runs are planned; `randomRunOffsetMs` spreads
|
|
342
|
+
machines that would otherwise all start on the same second, and is applied when
|
|
343
|
+
the run is planned so the stored time is the time it runs. Pass `[0, 0]` to
|
|
344
|
+
start exactly on the minute. Each scheduled job carries its run in the
|
|
345
|
+
`scheduledFor` property.
|
|
346
|
+
|
|
347
|
+
Storage comes from `JobRunSchedulePersistenceFactory` — in memory locally, the
|
|
348
|
+
platform database when deployed, where claiming a due run is atomic so several
|
|
349
|
+
instances can schedule the same machines safely.
|
|
350
|
+
|
|
283
351
|
---
|
|
284
352
|
|
|
285
353
|
## `Actor`
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Scheduled runs
|
|
2
|
+
|
|
3
|
+
Some work isn't started by a person or an event — it just needs to happen at a
|
|
4
|
+
certain time. A nightly reconciliation, a weekly digest, an invoice run on the
|
|
5
|
+
1st of the month. The **job run scheduler** starts jobs on a timetable, so a
|
|
6
|
+
machine that should run at 09:00 gets a job at 09:00 without anything asking it
|
|
7
|
+
to.
|
|
8
|
+
|
|
9
|
+
## Scheduling a machine
|
|
10
|
+
|
|
11
|
+
Give the scheduler a machine and a `Schedule`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {JobRunScheduler, Schedule, StateMachine} from "anbaric";
|
|
15
|
+
|
|
16
|
+
const reconciliation = new StateMachine("reconciliation", [/* … */]);
|
|
17
|
+
|
|
18
|
+
JobRunScheduler.instance().schedule(
|
|
19
|
+
reconciliation,
|
|
20
|
+
new Schedule(Schedule.everyDay(), [{ hours: 2, minutes: 0 }]),
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That's it. From then on a job is started on `reconciliation` at 02:00 every day,
|
|
25
|
+
in its start state, and progresses like any other job.
|
|
26
|
+
|
|
27
|
+
## Describing a timetable
|
|
28
|
+
|
|
29
|
+
A `Schedule` is **which days** and **what times on those days**, kept separate so
|
|
30
|
+
one shape covers everything:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
new Schedule(Schedule.everyDay(), [{ hours: 9, minutes: 0 }]); // 09:00 daily
|
|
34
|
+
new Schedule(Schedule.daysOfWeek([1, 2, 3, 4, 5]), [{ hours: 9, minutes: 0 },
|
|
35
|
+
{ hours: 17, minutes: 30 }]); // weekdays, twice
|
|
36
|
+
new Schedule(Schedule.daysOfMonth([1]), [{ hours: 0, minutes: 0 }]); // the 1st, midnight
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`daysOfWeek` takes 0 (Sunday) to 6 (Saturday); `daysOfMonth` takes calendar
|
|
40
|
+
dates. The day test is just a predicate, so anything you can express in code —
|
|
41
|
+
last working day of the quarter, every other Tuesday — is a schedule:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const quarterEnd = (date : Date) => [2, 5, 8, 11].includes(date.getMonth()) && date.getDate() === 28;
|
|
45
|
+
new Schedule(quarterEnd, [{ hours: 23, minutes: 0 }]);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Runs are planned before they happen
|
|
49
|
+
|
|
50
|
+
The scheduler doesn't wake up and ask "is anything due?". It **plans ahead** —
|
|
51
|
+
by default a day at a time — writing each future run to storage, then starts
|
|
52
|
+
runs as they come due. Two things follow from that, both deliberate:
|
|
53
|
+
|
|
54
|
+
- **A restart doesn't lose the timetable.** The plan is already stored, so the
|
|
55
|
+
scheduler picks up where it left off.
|
|
56
|
+
- **Downtime doesn't silently skip runs.** A scheduler that was down for two
|
|
57
|
+
days starts the runs it missed as soon as it comes back, rather than
|
|
58
|
+
pretending they never existed. If you don't want that catch-up for a
|
|
59
|
+
particular machine, make its action check `scheduledFor` and return early.
|
|
60
|
+
|
|
61
|
+
Every scheduled job carries the run it belongs to:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
processRun.run = async (job) => {
|
|
65
|
+
const scheduledFor = new Date(job.properties.get("scheduledFor"));
|
|
66
|
+
// …reconcile everything up to scheduledFor
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Spreading the load
|
|
71
|
+
|
|
72
|
+
Machines scheduled at the same time would otherwise all start on the same
|
|
73
|
+
second. Each machine gets a small random offset — up to two minutes by default —
|
|
74
|
+
applied **when the run is planned**, so the stored time is the time it really
|
|
75
|
+
runs. Control it per machine:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
JobRunScheduler.instance().schedule(
|
|
79
|
+
digest,
|
|
80
|
+
new Schedule(Schedule.everyDay(), [{ hours: 6, minutes: 0 }]),
|
|
81
|
+
1000 * 60 * 60 * 24, // plan a day ahead
|
|
82
|
+
[0, 1000 * 60 * 15], // start somewhere in the 15 minutes after 06:00
|
|
83
|
+
);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pass `[0, 0]` for a machine that must start exactly on the minute.
|
|
87
|
+
|
|
88
|
+
## Local and deployed
|
|
89
|
+
|
|
90
|
+
Like every other service, the scheduler resolves its storage through a factory.
|
|
91
|
+
Locally the plan is held in memory, so scheduling works on your laptop with no
|
|
92
|
+
setup — though a plan in memory dies with the process. Deployed, the platform
|
|
93
|
+
sets `ANBARIC_JOB_RUN_SCHEDULE_PERSISTENCE_TYPE=cloud` and the plan lives in the
|
|
94
|
+
platform database, surviving restarts and redeploys. **Your code is identical
|
|
95
|
+
either way** — see [Environment and factories](../api/environment.md).
|
|
96
|
+
|
|
97
|
+
Because the plan is shared, more than one instance of your app can run the
|
|
98
|
+
scheduler safely: claiming a due run is atomic, so each run is started exactly
|
|
99
|
+
once no matter how many instances are up.
|
|
100
|
+
|
|
101
|
+
## Scheduling and queueing are different things
|
|
102
|
+
|
|
103
|
+
A scheduled run is a **timetable entry**: "this machine should get a job at
|
|
104
|
+
09:00". `Queue.schedule` delays an **existing job**: "look at this job again in
|
|
105
|
+
ten minutes". Reach for the scheduler when the trigger is the clock, and for a
|
|
106
|
+
delayed enqueue when a job needs to wait before continuing.
|
|
@@ -61,6 +61,34 @@ Each time a job is processed, the machine:
|
|
|
61
61
|
Because actions only *propose* changes and the machine *applies* them, a job's
|
|
62
62
|
data is always schema-valid, whoever wrote it.
|
|
63
63
|
|
|
64
|
+
A state's actions run **every time** the job is processed in that state, not
|
|
65
|
+
once on entry. That is deliberate — it lets a predicate be time-based
|
|
66
|
+
(`(job) => Date.now() > retryAfter(job)`) or wait on something external. For
|
|
67
|
+
work that must happen only once, guard it:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
fetchReport.predicate = (job) => !job.properties.has("report");
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Failing a job
|
|
74
|
+
|
|
75
|
+
If an action throws, the job is marked **failed** (`Job.Status.FAILED`) and the
|
|
76
|
+
reason is recorded against it in the audit trail. You don't need to catch
|
|
77
|
+
errors yourself to stop a job getting stuck — throwing *is* how you say "this
|
|
78
|
+
job cannot proceed":
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
chargeCard.run = async (job) => {
|
|
82
|
+
const outcome = await payments.charge(job.properties.get("amount"));
|
|
83
|
+
if (!outcome.ok) throw new Error(`Card declined for job ${job.id}: ${outcome.reason}`);
|
|
84
|
+
return new Map([["charged", true]]);
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A failed job stays where it is rather than transitioning, but it isn't dead: an
|
|
89
|
+
update that moves it on clears the status, so correcting the data and calling
|
|
90
|
+
`updateJob` retries it.
|
|
91
|
+
|
|
64
92
|
## Terminal states
|
|
65
93
|
|
|
66
94
|
Mark the end of a process with `Terminal`, which carries an outcome:
|
|
@@ -77,6 +105,21 @@ new Terminal("cancelled", Terminal.Outcome.FAILURE),
|
|
|
77
105
|
|
|
78
106
|
A job that reaches a terminal state stops and is never processed again.
|
|
79
107
|
|
|
108
|
+
## Moving on unconditionally
|
|
109
|
+
|
|
110
|
+
A transition's predicate is optional. If a state's actions simply run and the
|
|
111
|
+
job should then move on, leave the guard off — there is no need to invent a
|
|
112
|
+
sentinel property for the transition to test:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
new State("enriching", [lookUpCompany], [new Transition("scoring")]),
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Guard a transition when the move is genuinely conditional — branching, or
|
|
119
|
+
waiting for something to become true. If the concern is "what if the action
|
|
120
|
+
fails?", throw from the action instead (see [Failing a job](#failing-a-job));
|
|
121
|
+
you don't need an error property and a guard that reads it.
|
|
122
|
+
|
|
80
123
|
## Branching
|
|
81
124
|
|
|
82
125
|
A state can have several transitions; the first satisfied one wins. Put the more
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# UX practices for Anbaric apps
|
|
2
|
+
|
|
3
|
+
Anbaric apps are asynchronous by nature: submitting a form doesn't finish the
|
|
4
|
+
work, it hands a job to a state machine that then moves on its own. A UI that
|
|
5
|
+
ignores that feels broken even when everything is working. These are the
|
|
6
|
+
practices to follow when you build a human-facing interface on Anbaric.
|
|
7
|
+
|
|
8
|
+
## Acknowledge a submission immediately
|
|
9
|
+
|
|
10
|
+
**Always give feedback the moment a job update is submitted.** `updateJob` and
|
|
11
|
+
`startJob` return once the change is *stored and queued* — not once the job has
|
|
12
|
+
progressed. If the page sits silent, the person cannot tell whether their click
|
|
13
|
+
registered, and will click again.
|
|
14
|
+
|
|
15
|
+
Say what happened and what is happening next:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
✓ Approval submitted — the job is being processed…
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Disable the button while the request is in flight so the same update can't be
|
|
22
|
+
sent twice.
|
|
23
|
+
|
|
24
|
+
## Then follow the job until it settles
|
|
25
|
+
|
|
26
|
+
After acknowledging, watch the job so the page reflects reality rather than a
|
|
27
|
+
guess. Poll it and re-render as the state changes:
|
|
28
|
+
|
|
29
|
+
- **Poll at most once per second.** Anything faster adds load without telling
|
|
30
|
+
the user anything new; a job that transitions immediately is still only
|
|
31
|
+
observable per processing pass.
|
|
32
|
+
- **Stop when there is nothing left to wait for** — the job reached a terminal
|
|
33
|
+
state, parked on an `Await`, or failed. Don't poll a settled job forever.
|
|
34
|
+
- **Show the state, not just a spinner.** "Awaiting approval", "Charging card",
|
|
35
|
+
"Failed — card declined" tells someone far more than an endless whirl.
|
|
36
|
+
- **Back off or stop after a reasonable period**, and say so, rather than
|
|
37
|
+
spinning indefinitely if nothing changes.
|
|
38
|
+
|
|
39
|
+
A job that has parked on an `Await` is waiting for a *person*, possibly not the
|
|
40
|
+
one at the screen. Say what it is waiting for rather than implying the page is
|
|
41
|
+
still loading.
|
|
42
|
+
|
|
43
|
+
## Surface failures honestly
|
|
44
|
+
|
|
45
|
+
A job whose action threw is `Job.Status.FAILED`, with the reason in its audit
|
|
46
|
+
trail. Show that the work stopped and why. Silently leaving the last-known state
|
|
47
|
+
on screen turns a failure into a mystery.
|
|
48
|
+
|
|
49
|
+
## Styling (optional)
|
|
50
|
+
|
|
51
|
+
If the user hasn't asked for a particular look, you may use the **Anbaric design
|
|
52
|
+
system** — design tokens, brand assets and React components:
|
|
53
|
+
<https://github.com/anbaric-ai/anbaric-cloud/tree/main/anbaric-design-system>.
|
|
54
|
+
It isn't published to npm, so copy in `tokens.css` and the components you need
|
|
55
|
+
rather than adding a dependency. If the user asked for something specific —
|
|
56
|
+
Tailwind, MUI, plain CSS, their own kit — use that instead; their choice wins.
|
|
57
|
+
|
|
58
|
+
## See also
|
|
59
|
+
|
|
60
|
+
- [Awaiting input](../features/awaiting-input.md) — pausing a job for a human
|
|
61
|
+
- [Human-in-the-loop](human-in-the-loop.md) — approvals, forms and hand-offs
|
|
62
|
+
- [Serving a web UI](../features/serving-a-web-ui.md) — putting data on a page
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.0",
|
|
4
4
|
"description": "Everything needed to write an Anbaric app: state machines, jobs, document and secret stores, local in-memory implementations and the Anbaric Cloud clients",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"prepublishOnly": "npm run build"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"anbaric-impl-cloud": "^1.
|
|
28
|
-
"anbaric-data-store": "^1.
|
|
29
|
-
"anbaric-state-machine": "^1.
|
|
30
|
-
"anbaric-tsapi": "^1.
|
|
27
|
+
"anbaric-impl-cloud": "^1.25.0",
|
|
28
|
+
"anbaric-data-store": "^1.25.0",
|
|
29
|
+
"anbaric-state-machine": "^1.25.0",
|
|
30
|
+
"anbaric-tsapi": "^1.25.0"
|
|
31
31
|
}
|
|
32
32
|
}
|