@hanzo/build 0.2.39 → 0.2.41

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/lib/ago.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** `just now`, `5m ago`, `3h ago`, `4d ago`, else the date, for an RFC 3339 instant; `—` for none. */
2
+ export declare function ago(iso: string): string;
package/lib/ago.js ADDED
@@ -0,0 +1,18 @@
1
+ /** `just now`, `5m ago`, `3h ago`, `4d ago`, else the date, for an RFC 3339 instant; `—` for none. */
2
+ export function ago(iso) {
3
+ const t = Date.parse(iso);
4
+ if (!Number.isFinite(t))
5
+ return '—';
6
+ const minutes = Math.round((Date.now() - t) / 60000);
7
+ if (minutes < 1)
8
+ return 'just now';
9
+ if (minutes < 60)
10
+ return `${minutes}m ago`;
11
+ const hours = Math.round(minutes / 60);
12
+ if (hours < 24)
13
+ return `${hours}h ago`;
14
+ const days = Math.round(hours / 24);
15
+ if (days < 30)
16
+ return `${days}d ago`;
17
+ return new Date(t).toLocaleDateString();
18
+ }
package/lib/api/auto.d.ts CHANGED
@@ -1,27 +1,86 @@
1
1
  /**
2
- * Automations: flows that run themselves.
2
+ * Automations: a task that runs itself, on a schedule or when asked.
3
3
  *
4
- * GET /v1/auto/flows {data: [flow]} newest updated first
5
- * GET /v1/auto/flows/{id} the flow and its latest version (the name)
6
- * POST /v1/auto/flows a disabled draft
7
- * POST /v1/auto/flows/{id}/enable
8
- * POST /v1/auto/flows/{id}/disable
4
+ * GET /v1/auto/automations {data: [automation]}
5
+ * POST /v1/auto/automations create one: name and instructions
6
+ * GET /v1/auto/automations/{id} one
7
+ * PATCH /v1/auto/automations/{id} change it, or switch it on or off
8
+ * DELETE /v1/auto/automations/{id} delete it, its schedule and its runs
9
+ * POST /v1/auto/automations/{id}/run {run: {id, status}} one run, now
10
+ * GET /v1/auto/automations/{id}/runs {data: [run]} newest first
9
11
  *
10
- * The list page records the id and whether the trigger is armed. The name lives
11
- * on the latest version, so each row is read once more.
12
+ * Each run is one Dev run in this organization, started as the person who last
13
+ * saved what it does. A draft is a flow named and not yet told what to do: it
14
+ * runs once its instructions are saved.
12
15
  */
13
16
  import { type Target } from './call.ts';
14
- export interface Automation {
15
- id: string;
17
+ export type Kind = 'manual' | 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'cron';
18
+ export type Permissions = 'ask' | 'auto';
19
+ /** When it runs, in the person's own words and zone. */
20
+ export interface Schedule {
21
+ kind: Kind;
22
+ /** HH:MM, 24-hour, for daily, weekdays and weekly; 00:MM for hourly. */
23
+ at?: string;
24
+ /** mon … sun, for weekly. */
25
+ day?: string;
26
+ /** Five fields, for cron. */
27
+ cron?: string;
28
+ /** An IANA zone; the platform reads an absent one as UTC. */
29
+ tz?: string;
30
+ }
31
+ /** What an automation is: what the editor holds and the platform keeps. */
32
+ export interface Draft {
16
33
  name: string;
17
- /** ENABLED or DISABLED. */
34
+ instructions: string;
35
+ /** A model id, or null for Enso, the default. */
36
+ model: string | null;
37
+ schedule: Schedule;
38
+ permissions: Permissions;
39
+ notify: boolean;
40
+ enabled: boolean;
41
+ }
42
+ export interface Last {
43
+ id: string;
18
44
  status: string;
19
- /** Unix milliseconds, or 0. */
20
- updated: number;
45
+ at: string;
46
+ summary: string;
47
+ }
48
+ export interface Automation extends Draft {
49
+ id: string;
50
+ project: string | null;
51
+ /** Named and not yet told what to do: it never runs until its instructions are saved. */
52
+ draft: boolean;
53
+ /** When it runs next, RFC 3339, or null when it runs only when asked or is off. */
54
+ next: string | null;
55
+ last: Last | null;
56
+ created: string;
57
+ updated: string;
21
58
  }
22
- export declare function automation(raw: unknown): Automation | null;
23
- export declare function flows(t: Target): Promise<Automation[]>;
24
- /** A disabled draft. Creating it does not arm the trigger. */
25
- export declare function add(t: Target, name: string): Promise<Automation>;
26
- /** Arm or disarm the trigger. A disarmed flow can still be started on demand. */
27
- export declare function arm(t: Target, id: string, on: boolean): Promise<void>;
59
+ /** One run: succeeded, failed, running, queued, skipped or refused. */
60
+ export interface Run {
61
+ id: string;
62
+ status: string;
63
+ at: string;
64
+ finished: string | null;
65
+ summary: string;
66
+ /** The Dev run it started, or null when it started none. */
67
+ session: string | null;
68
+ }
69
+ /** One automation as the platform answers it, or null for a row with no id. */
70
+ export declare function read(raw: unknown): Automation | null;
71
+ export declare function automations(t: Target): Promise<Automation[]>;
72
+ export declare function automation(t: Target, id: string): Promise<Automation>;
73
+ export declare function create(t: Target, d: Draft): Promise<Automation>;
74
+ /** What changed between two drafts, field by field: what a save sends. */
75
+ export declare function changes(was: Draft, now: Draft): Partial<Draft>;
76
+ /** Change an automation. A change to anything but `enabled` makes the caller the person it runs as. */
77
+ export declare function save(t: Target, id: string, change: Partial<Draft>): Promise<Automation>;
78
+ export declare function remove(t: Target, id: string): Promise<void>;
79
+ /** Start one run now. While one is going, the platform records this start as skipped. */
80
+ export declare function start(t: Target, id: string): Promise<{
81
+ id: string;
82
+ status: string;
83
+ }>;
84
+ export declare function runs(t: Target, id: string): Promise<Run[]>;
85
+ /** A schedule the way a person says it. */
86
+ export declare function words(s: Schedule): string;
package/lib/api/auto.js CHANGED
@@ -1,67 +1,127 @@
1
1
  /**
2
- * Automations: flows that run themselves.
2
+ * Automations: a task that runs itself, on a schedule or when asked.
3
3
  *
4
- * GET /v1/auto/flows {data: [flow]} newest updated first
5
- * GET /v1/auto/flows/{id} the flow and its latest version (the name)
6
- * POST /v1/auto/flows a disabled draft
7
- * POST /v1/auto/flows/{id}/enable
8
- * POST /v1/auto/flows/{id}/disable
4
+ * GET /v1/auto/automations {data: [automation]}
5
+ * POST /v1/auto/automations create one: name and instructions
6
+ * GET /v1/auto/automations/{id} one
7
+ * PATCH /v1/auto/automations/{id} change it, or switch it on or off
8
+ * DELETE /v1/auto/automations/{id} delete it, its schedule and its runs
9
+ * POST /v1/auto/automations/{id}/run {run: {id, status}} one run, now
10
+ * GET /v1/auto/automations/{id}/runs {data: [run]} newest first
9
11
  *
10
- * The list page records the id and whether the trigger is armed. The name lives
11
- * on the latest version, so each row is read once more.
12
+ * Each run is one Dev run in this organization, started as the person who last
13
+ * saved what it does. A draft is a flow named and not yet told what to do: it
14
+ * runs once its instructions are saved.
12
15
  */
13
16
  import { call, seg } from './call.js';
14
17
  const str = (v) => (typeof v === 'string' ? v : '');
15
- const obj = (v) => v && typeof v === 'object' ? v : {};
16
- export function automation(raw) {
18
+ const nullable = (v) => (typeof v === 'string' && v ? v : null);
19
+ const obj = (v) => (v && typeof v === 'object' ? v : {});
20
+ const KINDS = ['manual', 'hourly', 'daily', 'weekdays', 'weekly', 'cron'];
21
+ function schedule(raw) {
22
+ const o = obj(raw);
23
+ const kind = KINDS.includes(o.kind) ? o.kind : 'manual';
24
+ const out = { kind };
25
+ for (const k of ['at', 'day', 'cron', 'tz'])
26
+ if (str(o[k]))
27
+ out[k] = str(o[k]);
28
+ return out;
29
+ }
30
+ /** One automation as the platform answers it, or null for a row with no id. */
31
+ export function read(raw) {
17
32
  const o = obj(raw);
18
33
  const id = str(o.id);
19
34
  if (!id)
20
35
  return null;
21
- const version = obj(o.version);
36
+ const last = obj(o.last);
22
37
  return {
23
38
  id,
24
- name: str(version.displayName) || str(o.displayName) || id,
25
- status: str(o.status) || 'DISABLED',
26
- updated: typeof o.updated === 'number' ? o.updated : 0,
39
+ name: str(o.name) || id,
40
+ instructions: str(o.instructions),
41
+ project: nullable(o.project),
42
+ model: nullable(o.model),
43
+ schedule: schedule(o.schedule),
44
+ permissions: o.permissions === 'auto' ? 'auto' : 'ask',
45
+ notify: o.notify === true,
46
+ enabled: o.enabled === true,
47
+ draft: o.draft === true,
48
+ next: nullable(o.next),
49
+ last: str(last.id) ? { id: str(last.id), status: str(last.status), at: str(last.at), summary: str(last.summary) } : null,
50
+ created: str(o.created),
51
+ updated: str(o.updated),
27
52
  };
28
53
  }
29
- export async function flows(t) {
30
- const raw = await call(t, 'GET', '/v1/auto/flows');
31
- const data = Array.isArray(obj(raw).data) ? obj(raw).data : [];
32
- const rows = data.map(automation).filter((a) => a !== null).slice(0, 40);
33
- return Promise.all(rows.map(async (a) => {
34
- if (a.name !== a.id)
35
- return a;
36
- try {
37
- return automation(await call(t, 'GET', `/v1/auto/flows/${seg(a.id)}`)) ?? a;
38
- }
39
- catch {
40
- return a;
41
- }
42
- }));
54
+ /** The Dev run a transcript link opens: `…/dev?run=sess_…`. */
55
+ function session(link) {
56
+ const m = str(link).match(/[?&]run=(sess_[0-9a-f]{32})\b/);
57
+ return m ? m[1] : null;
58
+ }
59
+ const list = (raw) => (Array.isArray(obj(raw).data) ? obj(raw).data : []);
60
+ export async function automations(t) {
61
+ return list(await call(t, 'GET', '/v1/auto/automations'))
62
+ .map(read)
63
+ .filter((a) => a !== null);
64
+ }
65
+ export async function automation(t, id) {
66
+ const a = read(await call(t, 'GET', `/v1/auto/automations/${seg(id)}`));
67
+ if (!a)
68
+ throw new Error('The automation came back without an id');
69
+ return a;
70
+ }
71
+ export async function create(t, d) {
72
+ const a = read(await call(t, 'POST', '/v1/auto/automations', d));
73
+ if (!a)
74
+ throw new Error('The automation was created and came back without an id');
75
+ return a;
76
+ }
77
+ /** What changed between two drafts, field by field: what a save sends. */
78
+ export function changes(was, now) {
79
+ const out = {};
80
+ for (const k of Object.keys(now)) {
81
+ if (JSON.stringify(was[k]) !== JSON.stringify(now[k]))
82
+ out[k] = now[k];
83
+ }
84
+ return out;
85
+ }
86
+ /** Change an automation. A change to anything but `enabled` makes the caller the person it runs as. */
87
+ export async function save(t, id, change) {
88
+ const a = read(await call(t, 'PATCH', `/v1/auto/automations/${seg(id)}`, change));
89
+ if (!a)
90
+ throw new Error('The automation was saved and came back without an id');
91
+ return a;
92
+ }
93
+ export async function remove(t, id) {
94
+ await call(t, 'DELETE', `/v1/auto/automations/${seg(id)}`);
95
+ }
96
+ /** Start one run now. While one is going, the platform records this start as skipped. */
97
+ export async function start(t, id) {
98
+ const run = obj(obj(await call(t, 'POST', `/v1/auto/automations/${seg(id)}/run`)).run);
99
+ return { id: str(run.id), status: str(run.status) };
43
100
  }
44
- /** A disabled draft. Creating it does not arm the trigger. */
45
- export async function add(t, name) {
46
- const displayName = name.trim();
47
- if (!displayName)
48
- throw new Error('Name the automation');
49
- const raw = await call(t, 'POST', '/v1/auto/flows', {
50
- displayName,
51
- trigger: {
52
- name: 'trigger',
53
- type: 'PIECE_TRIGGER',
54
- displayName: 'Start',
55
- strategy: 'MANUAL',
56
- settings: { pieceName: 'core', triggerName: 'manual' },
57
- },
58
- });
59
- const row = automation(raw);
60
- if (!row)
61
- throw new Error('The automation was created and did not come back named');
62
- return row;
101
+ export async function runs(t, id) {
102
+ return list(await call(t, 'GET', `/v1/auto/automations/${seg(id)}/runs`))
103
+ .map((raw) => {
104
+ const o = obj(raw);
105
+ return { id: str(o.id), status: str(o.status), at: str(o.at), finished: nullable(o.finished), summary: str(o.summary), session: session(o.transcript) };
106
+ })
107
+ .filter((r) => r.id);
63
108
  }
64
- /** Arm or disarm the trigger. A disarmed flow can still be started on demand. */
65
- export async function arm(t, id, on) {
66
- await call(t, 'POST', `/v1/auto/flows/${seg(id)}/${on ? 'enable' : 'disable'}`);
109
+ const DAYS = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' };
110
+ /** A schedule the way a person says it. */
111
+ export function words(s) {
112
+ const zone = ` · ${s.tz || 'UTC'}`;
113
+ switch (s.kind) {
114
+ case 'manual':
115
+ return 'When you run it';
116
+ case 'hourly':
117
+ return `Every hour at :${(s.at ?? '00:00').slice(3) || '00'}`;
118
+ case 'daily':
119
+ return `Every day at ${s.at}${zone}`;
120
+ case 'weekdays':
121
+ return `Weekdays at ${s.at}${zone}`;
122
+ case 'weekly':
123
+ return `${DAYS[s.day ?? ''] ?? s.day} at ${s.at}${zone}`;
124
+ case 'cron':
125
+ return `Cron ${s.cron}${zone}`;
126
+ }
67
127
  }
@@ -46,10 +46,11 @@ export declare function add(t: Target, what: {
46
46
  host?: string;
47
47
  }): Promise<Machine>;
48
48
  /**
49
- * Whether a machine is up. The platform reports a hand-registered machine at its
50
- * registered status until a heartbeat arrives, so `online` counts only once one has.
49
+ * A machine's status as a person reads it. The platform reports a hand-registered
50
+ * machine at its registered status until a heartbeat arrives, so `online` holds only
51
+ * once one has; before that it is `not seen yet`, and nothing is there to take a run.
51
52
  */
52
- export declare function live(m: Machine): boolean;
53
+ export declare function state(m: Machine): string;
53
54
  /** Rename a machine, or drain it (`draining`) and bring it back (`online`). */
54
55
  export declare function change(t: Target, id: string, what: {
55
56
  label?: string;
@@ -58,11 +58,12 @@ export async function add(t, what) {
58
58
  return machine(await call(t, 'POST', '/v1/agent/targets', { label, kind: what.kind, ...(host ? { host } : {}) }));
59
59
  }
60
60
  /**
61
- * Whether a machine is up. The platform reports a hand-registered machine at its
62
- * registered status until a heartbeat arrives, so `online` counts only once one has.
61
+ * A machine's status as a person reads it. The platform reports a hand-registered
62
+ * machine at its registered status until a heartbeat arrives, so `online` holds only
63
+ * once one has; before that it is `not seen yet`, and nothing is there to take a run.
63
64
  */
64
- export function live(m) {
65
- return m.status === 'online' && m.seen !== '';
65
+ export function state(m) {
66
+ return m.status === 'online' && !m.seen ? 'not seen yet' : m.status;
66
67
  }
67
68
  /** Rename a machine, or drain it (`draining`) and bring it back (`online`). */
68
69
  export async function change(t, id, what) {
@@ -12,3 +12,9 @@ export declare const ENSO = "enso";
12
12
  /** A readable name for an id: `anthropic/claude-opus-5.5` → `Claude Opus 5.5`, `zen5-coder` → `Zen5 Coder`. */
13
13
  export declare function label(id: string): string;
14
14
  export declare function models(t: Target): Promise<Model[]>;
15
+ /**
16
+ * The models an agent may think with on a surface a customer reads: Hanzo's own
17
+ * SKUs — Enso, Zen and the free tier — by their Hanzo names, and none of the
18
+ * embedding, guard, rerank, speech or vision models, which hold no conversation.
19
+ */
20
+ export declare function skus(list: readonly Model[]): Model[];
package/lib/api/models.js CHANGED
@@ -40,3 +40,11 @@ export async function models(t) {
40
40
  })),
41
41
  ];
42
42
  }
43
+ /**
44
+ * The models an agent may think with on a surface a customer reads: Hanzo's own
45
+ * SKUs — Enso, Zen and the free tier — by their Hanzo names, and none of the
46
+ * embedding, guard, rerank, speech or vision models, which hold no conversation.
47
+ */
48
+ export function skus(list) {
49
+ return list.filter((m) => /^(enso|zen\d*|free)(-[a-z0-9]+)*$/.test(m.id) && !/-(embedding|guard|rerank|scribe|vl|voice)\b/.test(m.id));
50
+ }
@@ -14,7 +14,7 @@ export interface Place {
14
14
  label: string;
15
15
  /** laptop | gpu | cloud | … as the platform names it; 'sandbox' for the sandbox. */
16
16
  kind: string;
17
- /** online | offline | draining; the sandbox is always online. */
17
+ /** online | offline | draining | not seen yet, as machines.ts `state` reads it; the sandbox is always online. */
18
18
  status: string;
19
19
  /** "10 vCPU / 122G / 1× GB10", when the machine reported one. */
20
20
  capacity: string;
package/lib/api/places.js CHANGED
@@ -1,8 +1,8 @@
1
- import { machines } from './machines.js';
1
+ import { machines, state } from './machines.js';
2
2
  export const SANDBOX = { id: '', label: 'Hanzo sandbox', kind: 'sandbox', status: 'online', capacity: '' };
3
3
  export async function places(t) {
4
4
  const list = await machines(t);
5
- return [SANDBOX, ...list.map(({ id, label, kind, status, capacity }) => ({ id, label, kind, status, capacity }))];
5
+ return [SANDBOX, ...list.map((m) => ({ id: m.id, label: m.label, kind: m.kind, status: state(m), capacity: m.capacity }))];
6
6
  }
7
7
  /** Whether a run sent here now would be taken. */
8
8
  export const ready = (p) => p.status === 'online';
@@ -0,0 +1,5 @@
1
+ export declare function Automations(): import("react").JSX.Element;
2
+ /** One automation: what it does and when, its switch, Run now, and its runs. `new` writes one. */
3
+ export declare function Editor({ id }: {
4
+ id: string;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,298 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Automations: work that runs itself in this organization, on a schedule or
4
+ * when someone presses Run. Each run is one Dev run.
5
+ *
6
+ * -/automations the org's automations; a row opens its editor
7
+ * -/automations/new a new one: its name, what it does, when it runs
8
+ * -/automations/<id> its editor, its switch, Run now and its runs
9
+ *
10
+ * Every read and write is /v1/auto/automations (api/auto.ts), scoped to the
11
+ * org the builder is in. A save sends only what changed; the switch sends
12
+ * `enabled` alone, so turning one on or off never changes who it runs as.
13
+ */
14
+ import { SizableText, XStack, YStack } from '@hanzo/gui';
15
+ import { ChevronLeft, Plus, Workflow } from '@hanzogui/lucide-icons-2';
16
+ import { Button, Input, Switch, Textarea } from '@hanzo/ui';
17
+ import { StatusDot } from '@hanzo/ui/chat';
18
+ import { ChipSelect } from '@hanzo/ui/product';
19
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
20
+ import { ago } from './ago.js';
21
+ import { Access } from './access.js';
22
+ import { automation, automations, changes, create, remove, runs, save, start, words } from './api/auto.js';
23
+ import { Refusal } from './api/call.js';
24
+ import { ENSO, label as named, models, skus } from './api/models.js';
25
+ import { Choice, Confirm, Field, Line } from './customize/ui.js';
26
+ import { useRead } from './data.js';
27
+ import { useHost, useTarget } from './host.js';
28
+ import { path } from './route.js';
29
+ const zone = () => {
30
+ try {
31
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
32
+ }
33
+ catch {
34
+ return 'UTC';
35
+ }
36
+ };
37
+ const KINDS = [
38
+ { id: 'manual', label: 'When I run it' },
39
+ { id: 'hourly', label: 'Every hour' },
40
+ { id: 'daily', label: 'Every day' },
41
+ { id: 'weekdays', label: 'Weekdays' },
42
+ { id: 'weekly', label: 'Every week' },
43
+ { id: 'cron', label: 'Cron' },
44
+ ];
45
+ const DAYS = [
46
+ { id: 'mon', label: 'Monday' },
47
+ { id: 'tue', label: 'Tuesday' },
48
+ { id: 'wed', label: 'Wednesday' },
49
+ { id: 'thu', label: 'Thursday' },
50
+ { id: 'fri', label: 'Friday' },
51
+ { id: 'sat', label: 'Saturday' },
52
+ { id: 'sun', label: 'Sunday' },
53
+ ];
54
+ const PERMISSIONS = [
55
+ { id: 'ask', label: 'Ask first' },
56
+ { id: 'auto', label: 'Act on its own' },
57
+ ];
58
+ /** A run's status as the rail's dot draws it. */
59
+ const DOT = { succeeded: 'done', failed: 'error', running: 'running', queued: 'running', skipped: 'stopped', refused: 'stopped' };
60
+ const WORD = { succeeded: 'Succeeded', failed: 'Failed', running: 'Running', queued: 'Queued', skipped: 'Skipped', refused: 'Refused' };
61
+ const live = (s) => s === 'running' || s === 'queued';
62
+ /** The schedule a kind starts from, keeping whatever of `was` still applies. */
63
+ function shaped(kind, was) {
64
+ // A schedule that runs only when asked keeps no zone worth keeping: leaving it takes the person's own.
65
+ const tz = was.kind === 'manual' || !was.tz ? zone() : was.tz;
66
+ switch (kind) {
67
+ case 'manual':
68
+ return { kind };
69
+ case 'hourly':
70
+ return { kind, at: `00:${(was.at ?? '00:00').slice(3) || '00'}`, tz };
71
+ case 'daily':
72
+ case 'weekdays':
73
+ return { kind, at: was.kind === 'hourly' || !was.at ? '09:00' : was.at, tz };
74
+ case 'weekly':
75
+ return { kind, at: was.kind === 'hourly' || !was.at ? '09:00' : was.at, day: was.day || 'mon', tz };
76
+ case 'cron':
77
+ return { kind, cron: was.cron || '0 9 * * 1-5', tz };
78
+ }
79
+ }
80
+ /** What is wrong with a schedule before it is sent, or ''. */
81
+ function unready(s) {
82
+ if ((s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly') && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(s.at ?? ''))
83
+ return 'Time of day is HH:MM on a 24-hour clock.';
84
+ if (s.kind === 'cron' && (s.cron ?? '').trim().split(/\s+/).length !== 5)
85
+ return 'Cron is five fields: minute, hour, day of month, month, day of week.';
86
+ return '';
87
+ }
88
+ /** A schedule as the platform reads it: an hourly minute in two digits. */
89
+ function tidy(d) {
90
+ const s = d.schedule;
91
+ if (s.kind !== 'hourly')
92
+ return d;
93
+ const m = Math.min(59, Number((s.at ?? '').slice(3)) || 0);
94
+ return { ...d, schedule: { ...s, at: `00:${String(m).padStart(2, '0')}` } };
95
+ }
96
+ /** A local time and date for an RFC 3339 instant. */
97
+ const local = (iso) => {
98
+ const d = new Date(iso);
99
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
100
+ };
101
+ /** The editor's copy of an automation: what a save compares against. */
102
+ const drafted = (a) => ({
103
+ name: a.name,
104
+ instructions: a.instructions,
105
+ model: a.model,
106
+ schedule: a.schedule,
107
+ permissions: a.permissions,
108
+ notify: a.notify,
109
+ enabled: a.draft ? true : a.enabled,
110
+ });
111
+ /** `next`, keeping each field of `cur` the person changed from `was` since. */
112
+ function rebase(cur, was, next) {
113
+ const out = { ...next };
114
+ for (const k of Object.keys(next)) {
115
+ if (JSON.stringify(cur[k]) !== JSON.stringify(was[k]))
116
+ out[k] = cur[k];
117
+ }
118
+ return out;
119
+ }
120
+ /** A refusal in the platform's words; out of credit names where to add some. */
121
+ function said(e) {
122
+ if (e instanceof Refusal && e.status === 402)
123
+ return `${e.message || 'This organization is out of credit.'} Add credit in Settings → Billing.`;
124
+ return e.message;
125
+ }
126
+ export function Automations() {
127
+ const host = useHost();
128
+ const t = useTarget();
129
+ const signed = Boolean(host.person);
130
+ const [q, setQ] = useState('');
131
+ const [problem, setProblem] = useState('');
132
+ const list = useRead(signed ? () => automations(t) : null, [], [t, signed]);
133
+ const needle = q.trim().toLowerCase();
134
+ const shown = list.value.filter((a) => !needle || a.name.toLowerCase().includes(needle) || a.instructions.toLowerCase().includes(needle));
135
+ const open = (id) => host.go(path({ kind: 'automation', id }));
136
+ const flip = async (a, on) => {
137
+ setProblem('');
138
+ try {
139
+ await save(t, a.id, { enabled: on });
140
+ list.reload();
141
+ }
142
+ catch (e) {
143
+ setProblem(`${a.name}: ${said(e)}`);
144
+ }
145
+ };
146
+ return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: _jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: "Automations" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Work that runs itself in this organization, on a schedule or when you run it. Each run is a Dev run." })] }), _jsxs(Button, { size: "sm", shrink: 0, disabled: !signed, onPress: () => open('new'), children: [_jsx(Plus, { size: 14 }), "New automation"] })] }), _jsx(XStack, { justify: "flex-end", children: _jsx(YStack, { width: 240, maxW: "100%", children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search\u2026", "aria-label": "Search automations", disabled: !signed }) }) }), _jsx(Line, { children: problem }), list.error ? (_jsx(Empty, { says: list.error.message })) : !signed ? (_jsx(Empty, { says: "Sign in to see this organization's automations." })) : list.loading && list.value.length === 0 ? (_jsx(Empty, { says: "Reading automations\u2026" })) : shown.length === 0 ? (_jsx(Empty, { says: list.value.length === 0 ? 'No automations yet.' : 'Nothing matches.', children: list.value.length === 0 ? (_jsx(Button, { size: "sm", onPress: () => open('new'), children: "New automation" })) : null })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Name" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 220, children: "When" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 150, children: "Last run" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 56, style: { textAlign: 'right' }, children: "On" })] }), shown.map((a) => (_jsx(Item, { a: a, onOpen: () => open(a.id), onSwitch: (on) => void flip(a, on) }, a.id)))] }))] }) }));
147
+ }
148
+ /** One automation in the list: its name opens it, and its switch arms it. */
149
+ function Item({ a, onOpen, onSwitch }) {
150
+ const about = useId();
151
+ return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${a.name}`, "aria-describedby": about, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, minW: 0, style: { textAlign: 'left' }, children: a.name }), _jsxs(XStack, { id: about, items: "center", gap: "$3", children: [a.draft ? (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", children: "Draft" })) : null, _jsx(SizableText, { size: "$1", color: "$soft", width: 220, numberOfLines: 1, style: { textAlign: 'left' }, children: a.draft ? 'Needs instructions' : words(a.schedule) }), _jsxs(XStack, { width: 150, items: "center", gap: "$2", children: [a.last ? _jsx(StatusDot, { status: DOT[a.last.status] ?? 'idle' }) : null, _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: a.last ? `${WORD[a.last.status] ?? a.last.status} · ${ago(a.last.at)}` : 'Never run' })] })] })] }), _jsx(XStack, { width: 56, justify: "flex-end", children: _jsx(Switch, { checked: a.enabled, disabled: a.draft, onCheckedChange: onSwitch, "aria-label": `${a.name} on` }) })] }));
152
+ }
153
+ function Empty({ says, children }) {
154
+ return (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$4", px: "$4", py: "$8", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
155
+ }
156
+ const BLANK = () => ({ name: '', instructions: '', model: null, schedule: { kind: 'manual' }, permissions: 'ask', notify: false, enabled: true });
157
+ /** One automation: what it does and when, its switch, Run now, and its runs. `new` writes one. */
158
+ export function Editor({ id }) {
159
+ const host = useHost();
160
+ const t = useTarget();
161
+ const signed = Boolean(host.person);
162
+ const fresh = id === 'new';
163
+ // `tick` reads the automation again; `beat` reads its runs again.
164
+ const [tick, setTick] = useState(0);
165
+ const [beat, setBeat] = useState(0);
166
+ const saved = useRead(signed && !fresh ? () => automation(t, id) : null, null, [t, signed, id, tick]);
167
+ const history = useRead(signed && !fresh ? () => runs(t, id) : null, [], [t, signed, id, beat]);
168
+ const catalog = useRead(signed ? () => models(t) : null, [], [t, signed]);
169
+ // `base` is what the form started from; a save sends what changed since, and only that.
170
+ const [base, setBase] = useState(BLANK);
171
+ const [d, setD] = useState(BLANK);
172
+ const [ready, setReady] = useState(fresh);
173
+ const [busy, setBusy] = useState('');
174
+ const [note, setNote] = useState('');
175
+ const [asking, setAsking] = useState(false);
176
+ const here = useRef(true);
177
+ useEffect(() => {
178
+ here.current = true;
179
+ return () => {
180
+ here.current = false;
181
+ };
182
+ }, []);
183
+ const a = saved.value;
184
+ // The switch of a saved automation is saved as it moves; of a new one or a draft, with the rest.
185
+ const switchesAlone = Boolean(a && !a.draft);
186
+ const change = changes(base, d);
187
+ if (switchesAlone)
188
+ delete change.enabled;
189
+ const dirty = fresh ? Boolean(d.name.trim() || d.instructions.trim()) : Object.keys(change).length > 0;
190
+ // What is saved now becomes the form's start, unless the person is mid-edit: then
191
+ // their edit stands, and what they did not touch is not sent back over it.
192
+ const clean = useRef(true);
193
+ clean.current = !dirty;
194
+ useEffect(() => {
195
+ if (!a)
196
+ return;
197
+ if (clean.current) {
198
+ setBase(drafted(a));
199
+ setD(drafted(a));
200
+ }
201
+ setReady(true);
202
+ }, [a]);
203
+ // A run going is read every 4s; a scheduled automation's runs every 30s, so a tick's run appears.
204
+ const going = history.value.some((r) => live(r.status));
205
+ const every = going ? 4000 : a && a.enabled && a.schedule.kind !== 'manual' ? 30_000 : 0;
206
+ useEffect(() => {
207
+ if (!every)
208
+ return;
209
+ const timer = setInterval(() => setBeat((n) => n + 1), every);
210
+ return () => clearInterval(timer);
211
+ }, [every]);
212
+ const set = (p) => setD((x) => ({ ...x, ...p }));
213
+ const setWhen = (p) => setD((x) => ({ ...x, schedule: { ...x.schedule, ...p } }));
214
+ const wrong = unready(d.schedule);
215
+ const items = useMemo(() => {
216
+ const list = skus(catalog.value).filter((m) => m.id !== ENSO);
217
+ const kept = d.model && !list.some((m) => m.id === d.model) ? [{ id: d.model, label: named(d.model) }] : [];
218
+ return [{ id: ENSO, label: 'Enso', hint: 'picks a model for each step' }, ...kept, ...list];
219
+ }, [catalog.value, d.model]);
220
+ const chosen = items.find((m) => m.id === (d.model ?? ENSO)) ?? items[0];
221
+ const zones = useMemo(() => {
222
+ try {
223
+ return ['UTC', ...Intl.supportedValuesOf('timeZone').filter((z) => z !== 'UTC')].map((z) => ({ id: z, label: z }));
224
+ }
225
+ catch {
226
+ return [{ id: 'UTC', label: 'UTC' }];
227
+ }
228
+ }, []);
229
+ const act = async (what, work) => {
230
+ setBusy(what);
231
+ setNote('');
232
+ try {
233
+ await work();
234
+ }
235
+ catch (e) {
236
+ setNote(said(e));
237
+ }
238
+ finally {
239
+ setBusy('');
240
+ }
241
+ };
242
+ const keep = () => act('save', async () => {
243
+ if (fresh) {
244
+ const made = await create(t, tidy(d));
245
+ // Only while still here: a person who left during the create stays where they went.
246
+ if (here.current)
247
+ host.go(path({ kind: 'automation', id: made.id }), { replace: true });
248
+ return;
249
+ }
250
+ const sent = tidy(d);
251
+ const send = changes(base, sent);
252
+ if (switchesAlone)
253
+ delete send.enabled;
254
+ const now = Object.keys(send).length ? await save(t, id, send) : a;
255
+ // What was typed while the save was in flight stands.
256
+ setBase(drafted(now));
257
+ setD((cur) => rebase(cur, sent, drafted(now)));
258
+ setTick((n) => n + 1);
259
+ setNote('Saved.');
260
+ });
261
+ const flip = (on) => {
262
+ if (!switchesAlone)
263
+ return set({ enabled: on });
264
+ void act('switch', async () => {
265
+ await save(t, id, { enabled: on });
266
+ setTick((n) => n + 1);
267
+ });
268
+ };
269
+ const go = () => act('run', async () => {
270
+ const run = await start(t, id);
271
+ setBeat((n) => n + 1);
272
+ if (run.status === 'skipped')
273
+ setNote('Not started: the previous run is still going.');
274
+ });
275
+ const back = (how) => host.go(path({ kind: 'screen', screen: 'automations' }), how);
276
+ if (!signed)
277
+ return _jsx(Page, { title: "Automation", onBack: () => back(), says: "Sign in to see this organization's automations." });
278
+ if (!fresh && saved.error && !a)
279
+ return _jsx(Page, { title: "Automation", onBack: () => back(), says: saved.error.message });
280
+ if (!ready)
281
+ return _jsx(Page, { title: "Automation", onBack: () => back(), says: "Reading the automation\u2026" });
282
+ const s = d.schedule;
283
+ const on = switchesAlone ? Boolean(a?.enabled) : d.enabled;
284
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$5", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(Button, { size: "sm", variant: "ghost", onPress: () => back(), "aria-label": "All automations", children: [_jsx(ChevronLeft, { size: 14 }), "Automations"] }), _jsx(XStack, { flex: 1 }), _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: switchesAlone ? (on ? 'On' : 'Off') : on ? 'On when saved' : 'Off when saved' }), _jsx(Switch, { checked: on, disabled: busy === 'switch', onCheckedChange: flip, "aria-label": switchesAlone ? `${a.name} on` : 'On when saved' })] }), a && !a.draft ? (_jsx(Button, { size: "sm", variant: "outline", disabled: Boolean(busy) || dirty, onPress: () => void go(), children: busy === 'run' ? 'Starting…' : 'Run now' })) : null] }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { render: "h1", size: "$6", color: "$ink", numberOfLines: 1, children: fresh ? 'New automation' : a?.name }), _jsx(SizableText, { size: "$2", color: "$soft", children: fresh
285
+ ? 'Name it, say what it should do, and when. It runs as you, in this organization.'
286
+ : a?.draft
287
+ ? 'A draft: say what it should do and save it, and it runs.'
288
+ : [words(a.schedule), a.next && a.enabled ? `next ${local(a.next)}` : a.schedule.kind === 'manual' ? '' : 'off'].filter(Boolean).join(' · ') })] }), _jsx(Field, { label: "Name", children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "Morning briefing", "aria-label": "Automation name", autoFocus: fresh }) }), _jsx(Field, { label: "Instructions", hint: "What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.", children: _jsx(Textarea, { value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "Summarize what needs my attention today across calendar, email and messages.", "aria-label": "Instructions", rows: 7 }) }), _jsx(Field, { label: "When it runs", hint: s.kind === 'cron' ? 'Five fields: minute, hour, day of month, month, day of week.' : undefined, children: _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(ChipSelect, { name: "When it runs", label: KINDS.find((k) => k.id === s.kind).label, chosen: KINDS.find((k) => k.id === s.kind), items: KINDS, onChange: (k) => set({ schedule: shaped(k.id, s) }), placement: "bottom-start", width: 200 }), s.kind === 'weekly' ? _jsx(ChipSelect, { name: "Day", label: DAYS.find((x) => x.id === s.day)?.label ?? 'Monday', chosen: DAYS.find((x) => x.id === s.day), items: DAYS, onChange: (x) => setWhen({ day: x.id }), placement: "bottom-start", width: 180 }) : null, s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly' ? (_jsx(YStack, { width: 110, children: _jsx(Input, { value: s.at ?? '', onChangeText: (v) => setWhen({ at: v.trim() }), placeholder: "09:00", "aria-label": "Time of day", inputMode: "text" }) })) : null, s.kind === 'hourly' ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "at minute" }), _jsx(YStack, { width: 70, children: _jsx(Input, { value: (s.at ?? '00:00').slice(3), onChangeText: (v) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` }), placeholder: "00", "aria-label": "Minute", inputMode: "numeric" }) })] })) : null, s.kind === 'cron' ? (_jsx(YStack, { width: 200, children: _jsx(Input, { value: s.cron ?? '', onChangeText: (v) => setWhen({ cron: v }), placeholder: "0 9 * * 1-5", "aria-label": "Cron", autoCapitalize: "none" }) })) : null, s.kind !== 'manual' ? _jsx(ChipSelect, { name: "Time zone", label: s.tz || 'UTC', chosen: { id: s.tz || 'UTC', label: s.tz || 'UTC' }, items: zones, onChange: (z) => setWhen({ tz: z.id }), placeholder: "Search zones\u2026", placement: "bottom-start", width: 260 }) : null] }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ChipSelect, { name: "Model", label: chosen.label, chosen: chosen, items: items, footer: _jsx(Access, { items: items }), onChange: (m) => set({ model: m.id === ENSO ? null : m.id }), placeholder: "Search models\u2026", placement: "bottom-start", loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Permissions", hint: d.permissions === 'auto' ? 'Works and uses connectors without stopping.' : 'Reads and researches, changes nothing, and ends with the actions it would take for you to approve.', children: _jsx(Choice, { label: "Permissions", value: d.permissions, options: PERMISSIONS, onChange: (p) => set({ permissions: p }) }) }), _jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Email me when a run ends" }), _jsx(SizableText, { size: "$1", color: "$soft", children: "One line on how it went, with a link to the run." })] }), _jsx(Switch, { checked: d.notify, onCheckedChange: (v) => set({ notify: v }), "aria-label": "Email me when a run ends" })] }), _jsx(Line, { children: note || (dirty ? wrong : '') }), _jsxs(XStack, { gap: "$2", items: "center", children: [a ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => setAsking(true), children: "Delete" })) : null, _jsx(XStack, { flex: 1 }), a && dirty ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => {
289
+ setBase(drafted(a));
290
+ setD(drafted(a));
291
+ }, children: "Discard changes" })) : null, _jsx(Button, { size: "sm", disabled: Boolean(busy) || !dirty || !d.name.trim() || !d.instructions.trim() || Boolean(wrong), onPress: () => void keep(), children: busy === 'save' ? 'Saving…' : fresh ? 'Create' : 'Save' })] }), a ? (_jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Runs" }), history.error && !history.value.length ? (_jsx(Line, { children: history.error.message })) : !history.value.length ? (_jsx(SizableText, { size: "$2", color: "$soft", children: history.loading ? 'Reading its runs…' : a.draft ? 'It runs once its instructions are saved.' : 'No runs yet. Run now starts one.' })) : (_jsx(YStack, { role: "list", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", "aria-label": "Run history", children: history.value.map((r, i) => (_jsxs(XStack, { role: "listitem", items: "flex-start", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsx(YStack, { pt: "$1", children: _jsx(StatusDot, { status: DOT[r.status] ?? 'idle' }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: `${WORD[r.status] ?? r.status} · ${local(r.at)}` }), _jsx(SizableText, { size: "$1", color: "$soft", style: { whiteSpace: 'pre-wrap' }, children: r.summary || (live(r.status) ? 'Working…' : '') })] }), r.session ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => host.go(r.session), "aria-label": `Open the Dev run of ${local(r.at)}`, children: "Open run" })) : null] }, r.id))) }))] })) : null] }), a ? (_jsx(Confirm, { what: a.name, says: "It stops running, and its schedule and its runs go with it. A run going now is stopped.", open: asking, onOpenChange: setAsking, onYes: async () => {
292
+ await remove(t, a.id);
293
+ back({ replace: true });
294
+ } })) : null] }));
295
+ }
296
+ function Page({ title, says, onBack }) {
297
+ return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: _jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$4", children: [_jsx(XStack, { children: _jsxs(Button, { size: "sm", variant: "ghost", onPress: onBack, "aria-label": "All automations", children: [_jsx(ChevronLeft, { size: 14 }), "Automations"] }) }), _jsx(SizableText, { render: "h1", size: "$6", color: "$ink", children: title }), _jsx(SizableText, { size: "$2", color: "$soft", children: says })] }) }));
298
+ }
package/lib/builder.js CHANGED
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  *
5
5
  * / New — the empty state, a composer over the codebase it works on
6
6
  * /sess_… one run, live
7
- * /-/automations repeating work
7
+ * /-/automations work that runs itself; /-/automations/<id> one, to edit and run
8
8
  /-/codebases the forge's repositories
9
9
  * /-/sync bring granted repositories onto the forge
10
10
  * /-/projects the forge's boards
@@ -36,6 +36,7 @@ import { useMemo, useState } from 'react';
36
36
  import { safe } from './api/projects.js';
37
37
  import { pinBoard } from './choice.js';
38
38
  import { Customize } from './customize/index.js';
39
+ import { Editor } from './automations.js';
39
40
  import { useKept, useRecents } from './data.js';
40
41
  import { Forge } from './forge.js';
41
42
  import { HostProvider, useHost, useTarget } from './host.js';
@@ -100,7 +101,7 @@ export function nav(host, go) {
100
101
  { id: 'projects', label: 'Projects', icon: _jsx(Kanban, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'projects' })), active: screen === 'projects' },
101
102
  { id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
102
103
  { id: 'customize', label: 'Customize', icon: _jsx(SlidersHorizontal, { size: 16 }), onPress: () => go(path({ kind: 'customize', tab: 'skills' })), active: r.kind === 'customize' || screen === 'mcp' },
103
- { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' },
104
+ { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
104
105
  ];
105
106
  const more = [
106
107
  { id: 'codebases', label: 'Codebase', icon: _jsx(FolderGit2, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), active: screen === 'codebases' },
@@ -141,6 +142,8 @@ function Pane({ onStarted }) {
141
142
  return _jsx(Settings, { section: r.section });
142
143
  if (r.kind === 'customize')
143
144
  return _jsx(Customize, { tab: r.tab });
145
+ if (r.kind === 'automation')
146
+ return _jsx(Editor, { id: r.id }, r.id);
144
147
  if (r.kind === 'screen') {
145
148
  if (r.screen === 'artifacts')
146
149
  return _jsx(Artifacts, {});
package/lib/forge.js CHANGED
@@ -7,11 +7,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * issue opens New with that codebase and the issue as the ask.
8
8
  */
9
9
  import { SizableText, XStack, YStack } from '@hanzo/gui';
10
- import { ChevronDown, GitBranch, Plus, Settings, Workflow } from '@hanzogui/lucide-icons-2';
10
+ import { ChevronDown, GitBranch, Plus, Settings } from '@hanzogui/lucide-icons-2';
11
11
  import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
12
12
  import { useEffect, useState } from 'react';
13
- import { add, arm, flows } from './api/auto.js';
13
+ import { ago } from './ago.js';
14
14
  import { codebases, create } from './api/codebases.js';
15
+ import { Automations } from './automations.js';
15
16
  import { environments } from './api/environment.js';
16
17
  import { boards, issues } from './api/work.js';
17
18
  import { boardKey, pinBoard, pinCodebase, readPending, writePending } from './choice.js';
@@ -51,56 +52,6 @@ export function Forge({ screen }) {
51
52
  return _jsx(Projects, {});
52
53
  return _jsx(Issues, {});
53
54
  }
54
- function Automations() {
55
- const host = useHost();
56
- const t = useTarget();
57
- const signed = Boolean(host.person);
58
- const [q, setQ] = useState('');
59
- const [making, setMaking] = useState(false);
60
- const [tick, setTick] = useState(0);
61
- const [problem, setProblem] = useState('');
62
- const list = useRead(signed ? () => flows(t) : null, [], [t, signed, tick]);
63
- const needle = q.trim().toLowerCase();
64
- const shown = list.value.filter((a) => !needle || a.name.toLowerCase().includes(needle));
65
- const flip = async (a) => {
66
- setProblem('');
67
- try {
68
- await arm(t, a.id, a.status !== 'ENABLED');
69
- setTick((n) => n + 1);
70
- }
71
- catch (e) {
72
- setProblem(e.message);
73
- }
74
- };
75
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: "Automations" }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Repeating work for this organization. A new one starts off, so you can name it before it runs." })] }), _jsxs(Button, { size: "sm", shrink: 0, disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New automation"] })] }), _jsx(XStack, { justify: "flex-end", children: _jsx(YStack, { width: 240, maxW: "100%", children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search\u2026", "aria-label": "Search automations", disabled: !signed }) }) }), problem ? _jsx(Note, { children: problem }) : null, list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Empty, { says: "Sign in to see this organization's automations." })) : list.loading && list.value.length === 0 ? (_jsx(Empty, { says: "Reading automations\u2026" })) : shown.length === 0 ? (_jsx(Empty, { says: list.value.length === 0 ? 'No automations yet.' : 'Nothing matches.', children: list.value.length === 0 ? (_jsx(Button, { size: "sm", onPress: () => setMaking(true), children: "New automation" })) : null })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Name" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 80, style: { textAlign: 'right' }, children: "Status" }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: "Updated" })] }), shown.map((a) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: a.name }), _jsx(XStack, { render: "button", "aria-label": a.status === 'ENABLED' ? `Turn off ${a.name}` : `Turn on ${a.name}`, onPress: () => void flip(a), width: 80, justify: "flex-end", children: _jsx(SizableText, { size: "$1", color: "$ink", children: a.status === 'ENABLED' ? 'On' : 'Off' }) }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: a.updated ? when(new Date(a.updated).toISOString()) : '—' })] }, a.id)))] }))] }), _jsx(NewAutomation, { open: making, onOpenChange: setMaking, onCreate: async (name) => {
76
- await add(t, name);
77
- setMaking(false);
78
- setTick((n) => n + 1);
79
- } })] }));
80
- }
81
- function Empty({ says, children }) {
82
- return (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$4", px: "$4", py: "$8", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
83
- }
84
- function NewAutomation({ open, onOpenChange, onCreate, }) {
85
- const [name, setName] = useState('');
86
- const [busy, setBusy] = useState(false);
87
- const [error, setError] = useState('');
88
- const submit = async () => {
89
- setBusy(true);
90
- setError('');
91
- try {
92
- await onCreate(name);
93
- setName('');
94
- }
95
- catch (e) {
96
- setError(e.message);
97
- }
98
- finally {
99
- setBusy(false);
100
- }
101
- };
102
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsx(DialogTitle, { children: "New automation" }), _jsxs(YStack, { gap: "$3", pt: "$3", children: [_jsx(Input, { value: name, onChangeText: setName, placeholder: "Name", "aria-label": "Automation name" }), error ? _jsx(Note, { children: error }) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] })] }) }));
103
- }
104
55
  const PAGE = 25;
105
56
  function Codebases() {
106
57
  const host = useHost();
@@ -177,7 +128,7 @@ function Codebases() {
177
128
  pinCodebase(host.org, c);
178
129
  host.go('');
179
130
  };
180
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: host.org || 'Forge' }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Create and browse this organization\u2019s repositories." })] }), _jsxs(XStack, { render: "button", "aria-label": "Settings", shrink: 0, items: "center", gap: "$1.5", px: "$2", py: "$1", rounded: "$3", hoverStyle: { bg: '$hover' }, onPress: () => host.go('-/settings/environments'), children: [_jsx(Settings, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Settings" })] })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", shrink: 0, "$max-md": { display: 'none' }, children: _jsx(SizableText, { size: "$2", color: "$ink", children: "All repos" }) }), _jsx(YStack, { flex: 1, "$max-md": { display: 'none' } }), _jsx(YStack, { width: 240, minW: 0, shrink: 1, "$max-md": { width: 'auto', flex: 1 }, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find repo\u2026", "aria-label": "Find a repository", disabled: !signed }) }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync" }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New"] })] }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's repositories." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : shown.length === 0 ? (_jsx(Note, { children: list.value.length === 0 ? 'No repositories yet.' : 'Nothing matches.' })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: "Environment" }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 140, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Last updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Work on ${c.name}`, onPress: () => open(c), items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(GitBranch, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: c.name }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: envOf.get(c.name) === 'ready' ? 'Ready' : envOf.get(c.name) === 'proposed' ? 'To review' : '—' }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : when(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }), _jsx(NewRepo, { open: making, onOpenChange: setMaking, onCreate: async (name, description) => {
131
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: host.org || 'Forge' }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Create and browse this organization\u2019s repositories." })] }), _jsxs(XStack, { render: "button", "aria-label": "Settings", shrink: 0, items: "center", gap: "$1.5", px: "$2", py: "$1", rounded: "$3", hoverStyle: { bg: '$hover' }, onPress: () => host.go('-/settings/environments'), children: [_jsx(Settings, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Settings" })] })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", shrink: 0, "$max-md": { display: 'none' }, children: _jsx(SizableText, { size: "$2", color: "$ink", children: "All repos" }) }), _jsx(YStack, { flex: 1, "$max-md": { display: 'none' } }), _jsx(YStack, { width: 240, minW: 0, shrink: 1, "$max-md": { width: 'auto', flex: 1 }, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find repo\u2026", "aria-label": "Find a repository", disabled: !signed }) }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync" }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New"] })] }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's repositories." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : shown.length === 0 ? (_jsx(Note, { children: list.value.length === 0 ? 'No repositories yet.' : 'Nothing matches.' })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: "Environment" }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 140, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Last updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Work on ${c.name}`, onPress: () => open(c), items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(GitBranch, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: c.name }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: envOf.get(c.name) === 'ready' ? 'Ready' : envOf.get(c.name) === 'proposed' ? 'To review' : '—' }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : ago(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }), _jsx(NewRepo, { open: making, onOpenChange: setMaking, onCreate: async (name, description) => {
181
132
  const row = await create(t, name, description);
182
133
  pinCodebase(host.org, row);
183
134
  setMaking(false);
@@ -206,23 +157,6 @@ function NewRepo({ open, onOpenChange, onCreate, }) {
206
157
  };
207
158
  return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsx(DialogTitle, { children: "New repository" }), _jsxs(YStack, { gap: "$3", pt: "$3", children: [_jsx(Input, { value: name, onChangeText: setName, placeholder: "Name", "aria-label": "Repository name" }), _jsx(Input, { value: description, onChangeText: setDescription, placeholder: "Description", "aria-label": "Description" }), error ? _jsx(Note, { children: error }) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] })] }) }));
208
159
  }
209
- function when(iso) {
210
- const t = Date.parse(iso);
211
- if (!Number.isFinite(t))
212
- return '—';
213
- const minutes = Math.round((Date.now() - t) / 60000);
214
- if (minutes < 1)
215
- return 'just now';
216
- if (minutes < 60)
217
- return `${minutes}m ago`;
218
- const hours = Math.round(minutes / 60);
219
- if (hours < 24)
220
- return `${hours}h ago`;
221
- const days = Math.round(hours / 24);
222
- if (days < 30)
223
- return `${days}d ago`;
224
- return new Date(t).toLocaleDateString();
225
- }
226
160
  function Projects() {
227
161
  const host = useHost();
228
162
  const t = useTarget();
package/lib/route.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * '' the empty state: a new run
5
5
  * sess_<32 hex> one run
6
6
  * -/<screen> a builder screen
7
+ * -/automations/<id> one automation's editor and runs; `new` writes one
7
8
  * -/settings[/<section>] a section of Settings
8
9
  * -/customize[/<tab>] skills, connectors and plugins: what the agent brings to a run
9
10
  * -/plans the plans an organization can be on
@@ -37,9 +38,14 @@ export type Route = {
37
38
  } | {
38
39
  kind: 'project';
39
40
  slug: string;
41
+ } | {
42
+ kind: 'automation';
43
+ id: string;
40
44
  };
41
45
  export declare const SESSION: RegExp;
42
46
  export declare const SLUG: RegExp;
47
+ /** An automation's id, or `new` for one not yet written. */
48
+ export declare const AUTOMATION: RegExp;
43
49
  export declare function route(path: string): Route;
44
50
  /** The path for a route — the inverse of `route`. */
45
51
  export declare function path(r: Route): string;
package/lib/route.js CHANGED
@@ -19,6 +19,8 @@ export const SECTIONS = [
19
19
  export const TABS = ['skills', 'connectors', 'plugins', 'agents'];
20
20
  export const SESSION = /^sess_[0-9a-f]{32}$/;
21
21
  export const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
22
+ /** An automation's id, or `new` for one not yet written. */
23
+ export const AUTOMATION = /^(flow_[0-9a-f]{32}|new)$/;
22
24
  const SCREENS = ['artifacts', 'templates', 'codebases', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
23
25
  export function route(path) {
24
26
  const p = path.replace(/^\/+|\/+$/g, '');
@@ -32,6 +34,10 @@ export function route(path) {
32
34
  const tab = (p.slice('-/customize/'.length) || 'skills');
33
35
  return TABS.includes(tab) ? { kind: 'customize', tab } : { kind: 'new' };
34
36
  }
37
+ if (p.startsWith('-/automations/')) {
38
+ const id = p.slice('-/automations/'.length);
39
+ return AUTOMATION.test(id) ? { kind: 'automation', id } : { kind: 'new' };
40
+ }
35
41
  if (p.startsWith('-/')) {
36
42
  const s = p.slice(2);
37
43
  return SCREENS.includes(s) ? { kind: 'screen', screen: s } : { kind: 'new' };
@@ -55,5 +61,7 @@ export function path(r) {
55
61
  return r.tab === 'skills' ? '-/customize' : `-/customize/${r.tab}`;
56
62
  case 'project':
57
63
  return r.slug;
64
+ case 'automation':
65
+ return `-/automations/${r.id}`;
58
66
  }
59
67
  }
package/lib/section.js CHANGED
@@ -45,7 +45,7 @@ export function DevSection({ host, onPick, label = 'Runs', children, }) {
45
45
  onPick?.();
46
46
  };
47
47
  const screen = r.kind === 'screen' ? r.screen : '';
48
- return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'codebases', onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), children: "Codebase" }), _jsx(SidebarItem, { icon: _jsx(Kanban, { size: 16, "aria-hidden": true }), active: screen === 'projects', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
48
+ return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations' || r.kind === 'automation', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'codebases', onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), children: "Codebase" }), _jsx(SidebarItem, { icon: _jsx(Kanban, { size: 16, "aria-hidden": true }), active: screen === 'projects', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
49
49
  pinBoard(host.org, '');
50
50
  go(path({ kind: 'screen', screen: 'issues' }));
51
51
  }, children: "Issues" }), _jsx(SidebarItem, { icon: _jsx(SlidersHorizontal, { size: 16, "aria-hidden": true }), active: r.kind === 'customize', onPress: () => go(path({ kind: 'customize', tab: 'connectors' })), children: "Customize" }), _jsx(SidebarItem, { icon: _jsx(Blocks, { size: 16, "aria-hidden": true }), active: screen === 'artifacts', onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), children: "Artifacts" }), _jsx(SidebarItem, { icon: _jsx(LayoutTemplate, { size: 16, "aria-hidden": true }), active: screen === 'templates', onPress: () => go(path({ kind: 'screen', screen: 'templates' })), children: "Templates" }), children, _jsx(SidebarSection, { label: label, children: !host.person ? (_jsx(Note, { children: "Sign in to see your runs." })) : sessions.value.length ? (sessions.value.map((s) => (_jsx(SidebarItem, { icon: _jsx(StatusDot, { status: DOTS[s.status] ?? 'idle' }), active: r.kind === 'run' && r.id === s.id, onPress: () => go(s.id), children: s.title || 'Untitled run' }, s.id)))) : (_jsx(Note, { children: sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.' })) })] }));
@@ -11,7 +11,7 @@ import { MoreHorizontal, Plus } from '@hanzogui/lucide-icons-2';
11
11
  import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, Input } from '@hanzo/ui';
12
12
  import { CopyButton } from '@hanzo/ui/product';
13
13
  import { useEffect, useState } from 'react';
14
- import { add, change, KINDS, key, live, machines, remove } from '../api/machines.js';
14
+ import { add, change, KINDS, key, machines, remove, state } from '../api/machines.js';
15
15
  import { Confirm, Rename } from '../ask.js';
16
16
  import { useRead } from '../data.js';
17
17
  import { useHost, useTarget } from '../host.js';
@@ -26,11 +26,10 @@ const UNREAD = [];
26
26
  /** The line under a machine: what it is, where, how big, and what it is doing. */
27
27
  function about(m) {
28
28
  const load = m.running ? `${m.running} running` : m.sessions ? `${m.sessions} runs` : '';
29
- const state = m.status === 'online' && !live(m) ? 'not seen yet' : m.status || 'unknown';
30
- return [state, m.kind, m.host && m.host !== m.label ? m.host : '', m.capacity, load].filter(Boolean).join(' · ');
29
+ return [state(m) || 'unknown', m.kind, m.host && m.host !== m.label ? m.host : '', m.capacity, load].filter(Boolean).join(' · ');
31
30
  }
32
31
  function Dot({ m }) {
33
- const on = live(m);
32
+ const on = state(m) === 'online';
34
33
  return (_jsx(YStack, { width: 8, height: 8, rounded: 999, shrink: 0, bg: on ? '$green10' : 'transparent', borderWidth: on ? 0 : 1, borderColor: m.status === 'draining' ? '$yellow10' : '$soft', "aria-hidden": true }));
35
34
  }
36
35
  export function Machines() {
@@ -66,15 +65,15 @@ export function Machines() {
66
65
  setNote(e.message);
67
66
  }
68
67
  };
69
- return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Machines", detail: "Your organization\u2019s own computers. A run sent to one runs there instead of in the Hanzo sandbox." }), _jsxs(Group, { title: "Link a machine", detail: "Run these on it. It registers under its hostname, and stays online while hanzo link runs.", children: [_jsxs(XStack, { items: "flex-start", gap: "$2", px: "$3", py: "$2.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$2", color: "$ink", "aria-label": "Link commands", style: { ...mono, whiteSpace: 'pre-wrap' }, children: LINK }), _jsx(CopyButton, { value: LINK, label: "Copy the commands" })] }), _jsx(XStack, { children: _jsxs(Button, { size: "sm", variant: "ghost", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Register one by hand"] }) })] }), _jsx(Group, { title: "Linked", children: list.error ? (_jsx(Soft, { children: list.error.message })) : list.value === UNREAD ? (_jsx(Soft, { children: "Reading machines\u2026" })) : list.value.length === 0 ? (_jsx(Soft, { children: "No machine is linked yet." })) : (_jsx(Card, { children: list.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(Dot, { m: m }), title: m.label, detail: about(m), trailing: _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": `Actions for ${m.label}`, px: "$1.5", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [
70
- { key: 'rename', label: 'Rename', onSelect: () => setRenaming(m) },
71
- m.status === 'draining'
72
- ? { key: 'online', label: 'Take runs again', onSelect: () => void drain(m, 'online') }
73
- : { key: 'drain', label: 'Drain', description: 'Finish what it has, take nothing new', onSelect: () => void drain(m, 'draining') },
74
- { key: 'key', label: 'Claim key', onSelect: () => setKeying(m) },
75
- { type: 'separator' },
76
- { key: 'remove', label: 'Remove', destructive: true, onSelect: () => setRemoving(m) },
77
- ] }) }, m.id))) })) }), _jsx(Note, { children: note }), _jsx(Adding, { open: adding, onOpenChange: setAdding, onAdded: (m) => {
68
+ return (_jsxs(YStack, { gap: "$6", children: [_jsx(Heading, { title: "Machines", detail: "Your organization\u2019s own computers. A run sent to one runs there instead of in the Hanzo sandbox." }), _jsxs(Group, { title: "Link a machine", detail: "Run these on it. It registers under its hostname, and stays online while hanzo link runs.", children: [_jsxs(XStack, { items: "flex-start", gap: "$2", px: "$3", py: "$2.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$raised", children: [_jsx(SizableText, { flex: 1, minW: 0, size: "$2", color: "$ink", "aria-label": "Link commands", style: { ...mono, whiteSpace: 'pre-wrap' }, children: LINK }), _jsx(CopyButton, { value: LINK, label: "Copy the commands" })] }), _jsx(XStack, { children: _jsxs(Button, { size: "sm", variant: "ghost", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Register one by hand"] }) })] }), _jsxs(Group, { title: "Linked", children: [list.value === UNREAD ? (_jsx(Soft, { children: list.error ? list.error.message : 'Reading machines…' })) : list.value.length === 0 ? (_jsx(Soft, { children: "No machine is linked yet." })) : (_jsx(Card, { children: list.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(Dot, { m: m }), title: m.label, detail: about(m), trailing: _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": `Actions for ${m.label}`, px: "$1.5", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [
69
+ { key: 'rename', label: 'Rename', onSelect: () => setRenaming(m) },
70
+ m.status === 'draining'
71
+ ? { key: 'online', label: 'Take runs again', onSelect: () => void drain(m, 'online') }
72
+ : { key: 'drain', label: 'Drain', description: 'Finish what it has, take nothing new', onSelect: () => void drain(m, 'draining') },
73
+ { key: 'key', label: 'Claim key', onSelect: () => setKeying(m) },
74
+ { type: 'separator' },
75
+ { key: 'remove', label: 'Remove', destructive: true, onSelect: () => setRemoving(m) },
76
+ ] }) }, m.id))) })), list.error && list.value !== UNREAD ? _jsx(Soft, { children: list.error.message }) : null] }), _jsx(Note, { children: note }), _jsx(Adding, { open: adding, onOpenChange: setAdding, onAdded: (m) => {
78
77
  setNote(`${m.label} is registered`);
79
78
  list.reload();
80
79
  } }), _jsx(Rename, { open: renaming !== null, onOpenChange: (o) => !o && setRenaming(null), title: "Rename machine", name: renaming?.label ?? '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",