@hanzo/build 0.2.25 → 0.2.27

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.
@@ -1,9 +1,11 @@
1
1
  /**
2
- * What a run changed, read from the forge it pushed to, as the person asking.
2
+ * What a run changed, read from the forge it pushed to, as the person asking,
3
+ * and its pull request merged there.
3
4
  *
4
- * GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
5
- * GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
6
- * GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
5
+ * GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
6
+ * GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
7
+ * GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
8
+ * POST /v1/agent/coding/{session}/merge → {number, url, state, base}; 409 with the forge's reason
7
9
  *
8
10
  * Before the run pushes, changes answers empty, not 404.
9
11
  */
@@ -54,3 +56,19 @@ export declare function changes(raw: unknown): Changes;
54
56
  export declare function read(t: Target, session: string): Promise<Changes>;
55
57
  export declare function tree(t: Target, session: string, path?: string): Promise<Entry[]>;
56
58
  export declare function blob(t: Target, session: string, path: string): Promise<Blob>;
59
+ /** A pull request after its merge. */
60
+ export interface Merged {
61
+ number: number;
62
+ url: string;
63
+ /** `merged`. */
64
+ state: string;
65
+ /** The branch it was merged into. */
66
+ base: string;
67
+ }
68
+ /**
69
+ * Merge the run's pull request into its base, at the commit the forge holds
70
+ * now: a push after that is refused, not merged unseen. One already merged
71
+ * answers as it is; a conflict, a closed pull request or a rule on the base is
72
+ * a 409 carrying the forge's reason. Nothing is forced.
73
+ */
74
+ export declare function merge(t: Target, session: string): Promise<Merged>;
@@ -1,9 +1,11 @@
1
1
  /**
2
- * What a run changed, read from the forge it pushed to, as the person asking.
2
+ * What a run changed, read from the forge it pushed to, as the person asking,
3
+ * and its pull request merged there.
3
4
  *
4
- * GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
5
- * GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
6
- * GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
5
+ * GET /v1/agent/coding/{session}/changes commits base..head, each file's patch, the pull request
6
+ * GET /v1/agent/coding/{session}/tree?path= one directory at the run's branch (its base until it pushes)
7
+ * GET /v1/agent/coding/{session}/blob?path= one file there, in /v1/git's blob shape
8
+ * POST /v1/agent/coding/{session}/merge → {number, url, state, base}; 409 with the forge's reason
7
9
  *
8
10
  * Before the run pushes, changes answers empty, not 404.
9
11
  */
@@ -61,3 +63,13 @@ export async function tree(t, session, path = '') {
61
63
  export async function blob(t, session, path) {
62
64
  return file(await call(t, 'GET', `/v1/agent/coding/${seg(session)}/blob${query({ path })}`), path);
63
65
  }
66
+ /**
67
+ * Merge the run's pull request into its base, at the commit the forge holds
68
+ * now: a push after that is refused, not merged unseen. One already merged
69
+ * answers as it is; a conflict, a closed pull request or a rule on the base is
70
+ * a 409 carrying the forge's reason. Nothing is forced.
71
+ */
72
+ export async function merge(t, session) {
73
+ const o = obj(await call(t, 'POST', `/v1/agent/coding/${seg(session)}/merge`, {}));
74
+ return { number: num(o.number), url: str(o.url), state: str(o.state), base: str(o.base) };
75
+ }
@@ -2,7 +2,7 @@
2
2
  * Adding a repository to a project, which is how a project is published.
3
3
  *
4
4
  * POST /v1/platform/apps {repo, ref, name, partOf, mode, tag?}
5
- * → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}}
5
+ * → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}, notice?}
6
6
  * GET /v1/platform/builds → {builds: [{id, repo, commit, status, startedAt, duration}]}
7
7
  *
8
8
  * The declaration is a values file in universe. `branch` (the default) opens a
@@ -10,6 +10,11 @@
10
10
  * refuses to name an image that is not built yet, so publishing to main is two
11
11
  * calls: build on a branch, then commit that tag once the build is green. An
12
12
  * org admin gets both; anyone else gets the review link.
13
+ *
14
+ * A repository in the org's own code workspace is its project's site instead:
15
+ * built in a sandbox and served at the project's address when the build
16
+ * finishes. That answer is `declaration.live` with a `notice` saying so, and no
17
+ * review; its build is on the same board, building, succeeded or failed.
13
18
  */
14
19
  import { type Target } from './call.ts';
15
20
  export interface Declare {
@@ -39,6 +44,8 @@ export interface Declared {
39
44
  review: string;
40
45
  /** Whether anything can deploy from this write. */
41
46
  live: boolean;
47
+ /** What the platform says of this write in a sentence, or ''. */
48
+ notice: string;
42
49
  }
43
50
  export interface Build {
44
51
  id: string;
@@ -2,7 +2,7 @@
2
2
  * Adding a repository to a project, which is how a project is published.
3
3
  *
4
4
  * POST /v1/platform/apps {repo, ref, name, partOf, mode, tag?}
5
- * → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}}
5
+ * → 202 {app, build?: {id, job, image, status}, declaration: {mode, ref, review, live, …}, notice?}
6
6
  * GET /v1/platform/builds → {builds: [{id, repo, commit, status, startedAt, duration}]}
7
7
  *
8
8
  * The declaration is a values file in universe. `branch` (the default) opens a
@@ -10,6 +10,11 @@
10
10
  * refuses to name an image that is not built yet, so publishing to main is two
11
11
  * calls: build on a branch, then commit that tag once the build is green. An
12
12
  * org admin gets both; anyone else gets the review link.
13
+ *
14
+ * A repository in the org's own code workspace is its project's site instead:
15
+ * built in a sandbox and served at the project's address when the build
16
+ * finishes. That answer is `declaration.live` with a `notice` saying so, and no
17
+ * review; its build is on the same board, building, succeeded or failed.
13
18
  */
14
19
  import { call } from './call.js';
15
20
  const str = (v) => (typeof v === 'string' ? v : '');
@@ -49,6 +54,7 @@ export async function declare(t, d) {
49
54
  ref: str(dec.ref),
50
55
  review: https(str(dec.review)),
51
56
  live: dec.live === true,
57
+ notice: str(raw.notice),
52
58
  };
53
59
  }
54
60
  export async function builds(t) {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The org's projects: what it has built, and where each one is served.
3
3
  *
4
- * GET /v1/projects → Project[]
5
- * PATCH /v1/projects/{slug} rename it, or make it public or private
6
- * DELETE /v1/projects/{slug} delete it and take its site down
4
+ * GET /v1/projects → Project[]
5
+ * PATCH /v1/projects/{slug} rename it, or make it public or private
6
+ * DELETE /v1/projects/{slug} delete it and take its site down
7
+ * GET /v1/projects/{slug}/deployments → Deployment[], newest first
7
8
  *
8
9
  * `slug` is the one key every surface shares — the address under /dev, the
9
10
  * site's name, the `project` a run is tagged with. A project belongs to the org,
@@ -19,6 +20,11 @@ export interface Project {
19
20
  repo: string;
20
21
  /** The branch pushes to which rebuild it, or ''. */
21
22
  branch: string;
23
+ /**
24
+ * Where it stands: `building` while a project that has never served is being
25
+ * published, `live` once it serves, `error` when its build failed. A live
26
+ * project stays `live` while it is built again.
27
+ */
22
28
  status: string;
23
29
  /** The deployed address, or '' when nothing has shipped. */
24
30
  live: string;
@@ -42,6 +48,24 @@ export declare function change(t: Target, slug: string, what: {
42
48
  }): Promise<Project>;
43
49
  /** Delete a project. Its site stops answering and its slug is free again. */
44
50
  export declare function remove(t: Target, slug: string): Promise<void>;
51
+ /** One attempt to put a project on its address, as the platform recorded it. */
52
+ export interface Deployment {
53
+ id: string;
54
+ /** Counts the project's deployments from 1. */
55
+ version: number;
56
+ /** `building`, `live` or `error`, as the platform said. */
57
+ status: string;
58
+ /** The revision that was built, or ''. */
59
+ commit: string;
60
+ /** What happened, in words: the build's own note, or on a failure why it failed. */
61
+ message: string;
62
+ /** Unix seconds; 0 when unknown. */
63
+ created: number;
64
+ updated: number;
65
+ }
66
+ export declare function deployment(raw: unknown): Deployment;
67
+ /** A project's deployments, newest first: the first says how its latest build went. */
68
+ export declare function deployments(t: Target, slug: string): Promise<Deployment[]>;
45
69
  /** The repository name a project's clone URL names — the last path segment, without `.git`. */
46
70
  export declare function name(clone: string): string;
47
71
  /** A starter from the public catalog. */
@@ -60,7 +84,9 @@ export interface Template {
60
84
  export declare function templates(t: Target): Promise<Template[]>;
61
85
  /**
62
86
  * Take a copy of a starter: a project whose repository is the starter's own,
63
- * so what opens is that template rather than a model's imitation of it.
87
+ * so what opens is that template rather than a model's imitation of it. In an
88
+ * org whose code is in a workspace the platform made, the copy is published at
89
+ * once, and answers `building` until it is `live` or its build failed.
64
90
  */
65
91
  export declare function fork(t: Target, slug: string): Promise<Project>;
66
92
  /** `owner/name` from a clone URL — its last two path segments, without `.git`. */
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The org's projects: what it has built, and where each one is served.
3
3
  *
4
- * GET /v1/projects → Project[]
5
- * PATCH /v1/projects/{slug} rename it, or make it public or private
6
- * DELETE /v1/projects/{slug} delete it and take its site down
4
+ * GET /v1/projects → Project[]
5
+ * PATCH /v1/projects/{slug} rename it, or make it public or private
6
+ * DELETE /v1/projects/{slug} delete it and take its site down
7
+ * GET /v1/projects/{slug}/deployments → Deployment[], newest first
7
8
  *
8
9
  * `slug` is the one key every surface shares — the address under /dev, the
9
10
  * site's name, the `project` a run is tagged with. A project belongs to the org,
@@ -74,6 +75,26 @@ export async function change(t, slug, what) {
74
75
  export async function remove(t, slug) {
75
76
  await call(t, 'DELETE', `/v1/projects/${seg(slug)}`);
76
77
  }
78
+ export function deployment(raw) {
79
+ const d = (raw && typeof raw === 'object' ? raw : {});
80
+ return {
81
+ id: str(d.id),
82
+ version: num(d.version),
83
+ status: str(d.status),
84
+ commit: str(d.commit),
85
+ message: str(d.message),
86
+ created: num(d.createdAt),
87
+ updated: num(d.updatedAt),
88
+ };
89
+ }
90
+ /** A project's deployments, newest first: the first says how its latest build went. */
91
+ export async function deployments(t, slug) {
92
+ const raw = await call(t, 'GET', `/v1/projects/${seg(slug)}/deployments`);
93
+ return (Array.isArray(raw) ? raw : [])
94
+ .map(deployment)
95
+ .filter((d) => d.id)
96
+ .sort((a, b) => b.version - a.version);
97
+ }
77
98
  /** The repository name a project's clone URL names — the last path segment, without `.git`. */
78
99
  export function name(clone) {
79
100
  const cut = clone.replace(/\.git$/, '').replace(/\/+$/, '');
@@ -100,7 +121,9 @@ export async function templates(t) {
100
121
  }
101
122
  /**
102
123
  * Take a copy of a starter: a project whose repository is the starter's own,
103
- * so what opens is that template rather than a model's imitation of it.
124
+ * so what opens is that template rather than a model's imitation of it. In an
125
+ * org whose code is in a workspace the platform made, the copy is published at
126
+ * once, and answers `building` until it is `live` or its build failed.
104
127
  */
105
128
  export async function fork(t, slug) {
106
129
  return project(await call(t, 'POST', '/v1/projects/fork', { slug }));
package/lib/api/turn.d.ts CHANGED
@@ -39,6 +39,12 @@ export interface Outcome {
39
39
  * never put on screen as the reason.
40
40
  */
41
41
  problem: string;
42
+ /**
43
+ * The place in the run (its `seq`) of the latest status that said its work
44
+ * went live on its project's site, or 0. It is the cue to read the project
45
+ * again; the address shown is the project's own, never one an event names.
46
+ */
47
+ published: number;
42
48
  }
43
49
  /**
44
50
  * What the run narrated about how it ended, latest winning. Status text only:
package/lib/api/turn.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * The coding plane's vocabulary (apps/coding): `status` for a lifecycle move
13
13
  * (started, routed, done, error, stopped, paused), `tool-call` for a step,
14
14
  * `log` for a free line. A person's steering is a `control` {command, message}.
15
+ * A run on a project with a site ends `done` with `live` when it published what
16
+ * it pushed, or `unpublished` saying why it did not.
15
17
  *
16
18
  * `cards` is the transcript as it is drawn: the run's steps, and the agent's own
17
19
  * output read by what each part is (harness.ts). The output a card shows is what
@@ -62,7 +64,10 @@ function status(b, mode) {
62
64
  return 'Done — nothing to change';
63
65
  const pr = str(b.pr);
64
66
  const tail = str(b.prError) ? ' — the pull request could not be opened' : pr ? ` — ${pr}` : '';
65
- return branch ? `Pushed ${branch}${tail}` : `Done${tail}`;
67
+ // A run on a project with a site publishes what it pushed, or says why not.
68
+ const why = str(b.unpublished);
69
+ const site = why ? ` — not published: ${why}` : str(b.live) ? ' — published' : '';
70
+ return branch ? `Pushed ${branch}${tail}${site}` : `Done${tail}${site}`;
66
71
  }
67
72
  case 'error':
68
73
  return str(b.error) || 'The run hit an error';
@@ -175,7 +180,7 @@ export function steps(events) {
175
180
  * the branch and the pull request are read from the run's record (`pull`).
176
181
  */
177
182
  export function outcome(events) {
178
- const out = { status: '', problem: '' };
183
+ const out = { status: '', problem: '', published: 0 };
179
184
  for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
180
185
  if (e.kind !== 'status')
181
186
  continue;
@@ -184,6 +189,8 @@ export function outcome(events) {
184
189
  continue;
185
190
  out.status = str(b.status) || out.status;
186
191
  out.problem = str(b.prError) || out.problem;
192
+ if (str(b.live))
193
+ out.published = e.seq;
187
194
  }
188
195
  return out;
189
196
  }
package/lib/git.d.ts CHANGED
@@ -3,6 +3,18 @@ export declare function Git({ session, title, live }: {
3
3
  title: string;
4
4
  live: boolean;
5
5
  }): import("react").JSX.Element;
6
+ /**
7
+ * Merge for a run's pull request, shown while it is open: `POST …/merge` lands
8
+ * the run's branch in its base at the commit the forge holds now. One press is
9
+ * one merge — pressed again while it is out, nothing more is sent — and a
10
+ * refusal says the forge's reason. `onMerged` reads the run again, which is
11
+ * what closes the button: the pull request reads merged.
12
+ */
13
+ export declare function Merge({ session, open, onMerged }: {
14
+ session: string;
15
+ open: boolean;
16
+ onMerged: () => void;
17
+ }): import("react").JSX.Element | null;
6
18
  /** A unified diff's lines, coloured by what each does: the Git tab's, and a transcript's edit card's. */
7
19
  export declare function Patch({ text }: {
8
20
  text: string;
package/lib/git.js CHANGED
@@ -2,13 +2,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  /**
3
3
  * A run's Git tab: what it pushed, read from the forge. Diff is the net change
4
4
  * from its base, Review is the pull request it opened and what reviewers said,
5
- * Commits is its branch's history since the base. While the run works it is
6
- * read again every little while, because a run pushes when it finishes a step.
5
+ * with Merge while it is open, Commits is its branch's history since the base.
6
+ * While the run works it is read again every little while, because a run
7
+ * pushes when it finishes a step.
7
8
  */
8
9
  import { SizableText, XStack, YStack } from '@hanzo/gui';
9
10
  import { ChevronDown, ChevronRight } from '@hanzogui/lucide-icons-2';
11
+ import { Button } from '@hanzo/ui';
10
12
  import { useEffect, useState } from 'react';
11
- import { read } from './api/changes.js';
13
+ import { merge, read } from './api/changes.js';
12
14
  import { useRead } from './data.js';
13
15
  import { useHost, useTarget } from './host.js';
14
16
  import { Out } from './out.js';
@@ -39,7 +41,39 @@ export function Git({ session, title, live }) {
39
41
  : 'Open'
40
42
  : c.pull.state === 'merged'
41
43
  ? 'Merged'
42
- : 'Closed' }), c.pull.url.startsWith('https://') ? (_jsx(Out, { href: c.pull.url, label: `Open pull request #${c.pull.number}`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Open on the forge" }) })) : null] }), c.pull.reviews.length ? (c.pull.reviews.map((r, i) => (_jsxs(YStack, { gap: "$1", pb: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: [r.author || 'Someone', verdict(r.state), when(r.at)].filter(Boolean).join(' · ') }), r.body ? (_jsx(SizableText, { size: "$2", color: "$ink", children: r.body })) : null] }, i)))) : (_jsx(Soft, { children: "No reviews yet" }))] })) : (_jsx(Soft, { children: "No pull request yet. The run opens one when it pushes its changes." }))) : c.commits.length ? (_jsx(YStack, { children: c.commits.map((k) => (_jsxs(XStack, { gap: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: k.sha.slice(0, 7) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 2, children: k.message }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [k.author, when(k.date)].filter(Boolean).join(' · ') })] })] }, k.sha))) })) : (_jsx(Soft, { children: "No pushed commits" }))] }));
44
+ : 'Closed' }), c.pull.url.startsWith('https://') ? (_jsx(Out, { href: c.pull.url, label: `Open pull request #${c.pull.number}`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Open on the forge" }) })) : null, _jsx(Merge, { session: session, open: c.pull.state === 'open', onMerged: reload })] }), c.pull.reviews.length ? (c.pull.reviews.map((r, i) => (_jsxs(YStack, { gap: "$1", pb: "$2", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: [r.author || 'Someone', verdict(r.state), when(r.at)].filter(Boolean).join(' · ') }), r.body ? (_jsx(SizableText, { size: "$2", color: "$ink", children: r.body })) : null] }, i)))) : (_jsx(Soft, { children: "No reviews yet" }))] })) : (_jsx(Soft, { children: "No pull request yet. The run opens one when it pushes its changes." }))) : c.commits.length ? (_jsx(YStack, { children: c.commits.map((k) => (_jsxs(XStack, { gap: "$3", py: "$2", borderBottomWidth: 1, borderColor: "$borderColor", items: "baseline", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: k.sha.slice(0, 7) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 2, children: k.message }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [k.author, when(k.date)].filter(Boolean).join(' · ') })] })] }, k.sha))) })) : (_jsx(Soft, { children: "No pushed commits" }))] }));
45
+ }
46
+ /**
47
+ * Merge for a run's pull request, shown while it is open: `POST …/merge` lands
48
+ * the run's branch in its base at the commit the forge holds now. One press is
49
+ * one merge — pressed again while it is out, nothing more is sent — and a
50
+ * refusal says the forge's reason. `onMerged` reads the run again, which is
51
+ * what closes the button: the pull request reads merged.
52
+ */
53
+ export function Merge({ session, open, onMerged }) {
54
+ const t = useTarget();
55
+ const [busy, setBusy] = useState(false);
56
+ const [note, setNote] = useState('');
57
+ const press = async () => {
58
+ if (busy)
59
+ return;
60
+ setBusy(true);
61
+ setNote('');
62
+ try {
63
+ const done = await merge(t, session);
64
+ setNote(done.base ? `Merged into ${done.base}` : 'Merged');
65
+ onMerged();
66
+ }
67
+ catch (e) {
68
+ setNote(e.message);
69
+ }
70
+ finally {
71
+ setBusy(false);
72
+ }
73
+ };
74
+ if (!open && !note)
75
+ return null;
76
+ return (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [open ? (_jsx(Button, { size: "sm", variant: "outline", "aria-busy": busy, onPress: () => void press(), children: busy ? 'Merging…' : 'Merge' })) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null] }));
43
77
  }
44
78
  function File({ change: f, first }) {
45
79
  const [open, setOpen] = useState(false);
package/lib/project.js CHANGED
@@ -16,6 +16,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
16
16
  * preview is the project's own deployed address; a run's sandbox serves no
17
17
  * address the platform publishes, and nothing here invents one.
18
18
  *
19
+ * A project in a workspace the platform made is published by the platform: a
20
+ * template taken there reads `building` until its copy is `live` or failed, and
21
+ * is read again until then; a run that publishes what it pushed says so on its
22
+ * final status, which reads the project again and loads the preview again. Its
23
+ * pull request is merged from beside its link.
24
+ *
19
25
  * Derived from the Hanzo App v2 editor (github.com/hanzoai/build-v2), itself
20
26
  * derived from OSW Studio and DeepSite (MIT). See NOTICE.
21
27
  */
@@ -26,19 +32,23 @@ import { Composer, Message } from '@hanzo/ui/chat';
26
32
  import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
27
33
  import { HanzoMark } from '@hanzo/ui/product';
28
34
  import { useEffect, useMemo, useRef, useState } from 'react';
35
+ import { read as pushed } from './api/changes.js';
29
36
  import { start } from './api/coding.js';
30
37
  import { blob, tree } from './api/git.js';
31
- import { name as repoName, ours, templates } from './api/projects.js';
38
+ import { deployments, name as repoName, ours, templates } from './api/projects.js';
32
39
  import { list, message, stop } from './api/sessions.js';
33
40
  import { outcome, pull, said, who } from './api/turn.js';
34
41
  import { verdict as record } from './api/verdict.js';
35
42
  import { useKept, useProjects, useRead, useRun } from './data.js';
43
+ import { Merge } from './git.js';
36
44
  import { useHost, useTarget } from './host.js';
37
45
  import { MODES } from './landing.js';
38
46
  import { Out } from './out.js';
39
47
  import { Publish } from './publish.js';
40
48
  import { Attach, compose, Dictate, Files } from './tools.js';
41
49
  const LIVE = new Set(['running', 'paused']);
50
+ /** How often a project being published is read again, ms. */
51
+ const AGAIN = 5000;
42
52
  /** One run in the conversation: what was asked, and what it came to. */
43
53
  function Turn({ run, open, onOpen, project }) {
44
54
  const t = useTarget();
@@ -58,6 +68,19 @@ export function Project({ slug }) {
58
68
  const signed = Boolean(host.person);
59
69
  const all = useProjects(t, signed);
60
70
  const project = all.value.find((p) => p.slug === slug) ?? null;
71
+ // A project that has never served reads `building` while it is published, and
72
+ // is read again until it is live or its build failed.
73
+ const building = project?.status === 'building';
74
+ const failed = project?.status === 'error';
75
+ const { reload: again } = all;
76
+ useEffect(() => {
77
+ if (!building)
78
+ return;
79
+ const every = setInterval(again, AGAIN);
80
+ return () => clearInterval(every);
81
+ }, [building, again]);
82
+ // Why the build failed is its latest deployment's to say.
83
+ const failure = useRead(failed ? () => deployments(t, slug) : null, [], [t, slug, failed, project?.updated]);
61
84
  const listed = useRead(signed ? () => list(t, { kind: 'coding', project: slug, limit: 50 }) : null, [], [t, signed, slug]);
62
85
  // Only the runs on this project's own repository: a record can be moved into
63
86
  // any project, and a run's branch is what Files, Code and Publish read.
@@ -91,6 +114,36 @@ export function Project({ slug }) {
91
114
  const [publish, setPublish] = useState(null);
92
115
  const [switcher, setSwitcher] = useState(false);
93
116
  const frame = useRef(null);
117
+ // What was published may be new files at the same address: the project is read
118
+ // for its address, and the frame loads again.
119
+ const fresh = () => {
120
+ again();
121
+ frame.current?.reload();
122
+ };
123
+ // The open run's final status says when it published what it pushed. That is
124
+ // narration, which any member can write, so it is the cue to read the project
125
+ // and never the address framed; one already there when the run was first read
126
+ // is not news.
127
+ const heard = useRef({ run: null, at: 0 });
128
+ useEffect(() => {
129
+ if (!run.record)
130
+ return;
131
+ if (heard.current.run !== current) {
132
+ heard.current = { run: current, at: end.published };
133
+ return;
134
+ }
135
+ if (end.published <= heard.current.at)
136
+ return;
137
+ heard.current.at = end.published;
138
+ fresh();
139
+ // `fresh` reads what it needs when it runs.
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [current, run.record, end.published]);
142
+ // Where the open run's pull request stands on the forge: Merge is offered while it is open.
143
+ const proposal = useRead(signed && current && pr.href ? () => pushed(t, current).then((c) => ({ run: current, pull: c.pull })) : null, null, [t, signed, current, pr.href]);
144
+ const proposing = proposal.value?.run === current && proposal.value.pull?.state === 'open';
145
+ // Files is drawn again after a merge, from what the forge holds now.
146
+ const [round, setRound] = useState(0);
94
147
  // Files and Code: native git, at the branch the open run pushed (or the project's own).
95
148
  const repo = project?.repo ? repoName(project.repo) : '';
96
149
  const ref = run.record?.branch || project?.branch || 'main';
@@ -124,13 +177,19 @@ export function Project({ slug }) {
124
177
  .filter((e) => !e.dir && /\.html?$/i.test(e.name) && !/^index\.html?$/i.test(e.name))
125
178
  .map((e) => ({ id: `/${e.path}`, label: e.name.replace(/\.html?$/i, '') })),
126
179
  ], [root.value]);
127
- // A project taken from a starter and not yet published shows the starter's own
128
- // live page, which is what the copy is until its first publish.
129
- const forked = project && !project.live ? project.forked : '';
180
+ // A project taken from a starter, not published and not being, shows the
181
+ // starter's own live page, which is what the copy is until its first publish.
182
+ const forked = project && !project.live && !building && !failed ? project.forked : '';
130
183
  const starters = useRead(forked ? () => templates(t) : null, [], [t.api, forked]);
131
184
  const starter = forked ? starters.value.find((s) => s.slug === forked && s.demo) : undefined;
132
185
  const shown = project?.live || starter?.demo || '';
133
186
  const src = shown ? new URL(page, shown).toString() : null;
187
+ const why = failure.value[0]?.message || failure.error?.message || (failure.loading ? '' : 'Its build ended without saying why.');
188
+ const merged = () => {
189
+ proposal.reload();
190
+ root.reload();
191
+ setRound((n) => n + 1);
192
+ };
134
193
  const lines = useMemo(() => [
135
194
  ...run.events
136
195
  .filter((e) => e.kind === 'log' || e.kind === 'tool-call' || e.kind === 'status')
@@ -229,18 +288,20 @@ export function Project({ slug }) {
229
288
  ? 'Reading this project’s runs…'
230
289
  : project?.live
231
290
  ? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
232
- : starter
233
- ? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
234
- : project
235
- ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
236
- : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
291
+ : building
292
+ ? `${project?.name} is being published — it appears in the preview when its build finishes. Say what to change and it gets built.`
293
+ : starter
294
+ ? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
295
+ : project
296
+ ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
297
+ : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
237
298
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
238
299
  : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] })] }));
239
- const body = view === 'preview' ? (_jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Nothing deployed yet" }), _jsx(SizableText, { size: "$2", color: "$soft", text: "center", children: "Publish this project and its page appears here." })] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
300
+ const body = view === 'preview' ? (_jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: building ? (project?.forked ? 'Publishing your copy…' : 'Publishing…') : failed ? 'Publishing failed' : 'Nothing deployed yet' }), _jsx(SizableText, { size: "$2", color: "$soft", text: "center", children: building ? 'Its page appears here when the build finishes.' : failed ? why : 'Publish this project and its page appears here.' })] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
240
301
  const next = !editing;
241
302
  setEditing(next);
242
303
  frame.current?.post({ type: 'preview:editable', active: next });
243
- }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })) : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` })) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
304
+ }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })) : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
244
305
  setFiles((was) => was.filter((f) => f.path !== p));
245
306
  if (file === p)
246
307
  setFile(null);
@@ -259,5 +320,5 @@ export function Project({ slug }) {
259
320
  }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: all.value.map((p) => (_jsx(XStack, { render: "button", onPress: () => {
260
321
  setSwitcher(false);
261
322
  host.go(p.slug);
262
- }, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null) })] }));
323
+ }, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
263
324
  }
package/lib/publish.d.ts CHANGED
@@ -9,7 +9,8 @@ export interface Source {
9
9
  /** The project to add it to, when there already is one. */
10
10
  project?: string;
11
11
  }
12
- export declare function Publish({ source, onClose }: {
12
+ export declare function Publish({ source, onClose, onLive }: {
13
13
  source: Source | null;
14
14
  onClose: () => void;
15
+ onLive?: () => void;
15
16
  }): import("react").JSX.Element | null;
package/lib/publish.js CHANGED
@@ -9,17 +9,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * build to main, which CD applies: that is the second call, with the build's
10
10
  * tag, because main refuses an image that is not built yet.
11
11
  *
12
+ * A repository in the org's own code workspace answers `live` instead: its
13
+ * build puts it on the project's address when it finishes, so there is no
14
+ * review and nothing to take to main. Its build is followed on the board like
15
+ * any other, and `onLive` is told when it succeeds, so the host reads the
16
+ * project's address again.
17
+ *
12
18
  * Every state shown is one the platform reported; a refusal shows the
13
19
  * platform's own sentence.
14
20
  */
15
21
  import { SizableText, XStack, YStack } from '@hanzo/gui';
16
22
  import { ExternalLink } from '@hanzogui/lucide-icons-2';
17
23
  import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input } from '@hanzo/ui';
18
- import { useEffect, useState } from 'react';
24
+ import { useEffect, useRef, useState } from 'react';
19
25
  import { builds, declare, label } from './api/platform.js';
20
26
  import { useHost, useTarget } from './host.js';
21
27
  import { Out } from './out.js';
22
- export function Publish({ source, onClose }) {
28
+ export function Publish({ source, onClose, onLive }) {
23
29
  const t = useTarget();
24
30
  const host = useHost();
25
31
  const [project, setProject] = useState('');
@@ -27,6 +33,8 @@ export function Publish({ source, onClose }) {
27
33
  const [out, setOut] = useState(null);
28
34
  const [status, setStatus] = useState('');
29
35
  const [error, setError] = useState('');
36
+ const told = useRef(onLive);
37
+ told.current = onLive;
30
38
  useEffect(() => {
31
39
  setProject(source?.project ?? source?.name ?? '');
32
40
  setPhase('ask');
@@ -57,6 +65,12 @@ export function Publish({ source, onClose }) {
57
65
  clearInterval(every);
58
66
  };
59
67
  }, [t, out, phase]);
68
+ const green = /^(succeeded|success|done|complete|completed|ok)$/i.test(status);
69
+ // A site's build going green is the site going live, at the project's address.
70
+ useEffect(() => {
71
+ if (out?.live && green)
72
+ told.current?.();
73
+ }, [out, green]);
60
74
  if (!source)
61
75
  return null;
62
76
  const send = async () => {
@@ -75,7 +89,6 @@ export function Publish({ source, onClose }) {
75
89
  };
76
90
  // The build this write started; its status is what the platform last said of it.
77
91
  const built = out?.build?.id ?? '';
78
- const green = /^(succeeded|success|done|complete|completed|ok)$/i.test(status);
79
92
  // Offered only once that build is green.
80
93
  const ship = async () => {
81
94
  setPhase('shipping');
@@ -89,7 +102,11 @@ export function Publish({ source, onClose }) {
89
102
  setPhase('declared');
90
103
  }
91
104
  };
92
- return (_jsx(Dialog, { open: true, onOpenChange: (o) => !o && onClose(), children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Add to project" }), _jsxs(DialogDescription, { children: ["Build ", source.title, " at ", source.ref, " and declare it in a project. ", host.admin ? 'As an admin you can take a green build to main.' : 'It opens a review; merging it is what deploys.'] }), _jsxs(YStack, { gap: "$1.5", children: [_jsx(SizableText, { render: "label", size: "$2", color: "$soft", htmlFor: "publish-project", children: "Project" }), _jsx(Input, { id: "publish-project", value: project, onChangeText: (v) => setProject(v), disabled: phase !== 'ask' && phase !== 'failed', placeholder: label(source.name), "aria-label": "Project" }), _jsxs(SizableText, { size: "$1", color: "$soft", children: ["Saved as ", label(project || source.name)] })] }), out ? (_jsxs(YStack, { gap: "$2", role: "status", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: phase === 'shipped'
105
+ return (_jsx(Dialog, { open: true, onOpenChange: (o) => !o && onClose(), children: _jsxs(DialogContent, { maxW: 480, children: [_jsx(DialogTitle, { children: "Add to project" }), _jsx(DialogDescription, { children: out?.live
106
+ ? `Build ${source.title} at ${source.ref} and publish it at its project’s address.`
107
+ : `Build ${source.title} at ${source.ref} and declare it in a project. ${host.admin ? 'As an admin you can take a green build to main.' : 'It opens a review; merging it is what deploys.'}` }), _jsxs(YStack, { gap: "$1.5", children: [_jsx(SizableText, { render: "label", size: "$2", color: "$soft", htmlFor: "publish-project", children: "Project" }), _jsx(Input, { id: "publish-project", value: project, onChangeText: (v) => setProject(v), disabled: phase !== 'ask' && phase !== 'failed', placeholder: label(source.name), "aria-label": "Project" }), _jsxs(SizableText, { size: "$1", color: "$soft", children: ["Saved as ", label(project || source.name)] })] }), out ? (_jsxs(YStack, { gap: "$2", role: "status", children: [out.notice ? (_jsx(SizableText, { size: "$2", color: "$soft", children: out.notice })) : null, _jsx(SizableText, { size: "$2", color: "$ink", children: phase === 'shipped'
93
108
  ? 'Declared on main — CD applies it on its next pass.'
94
- : `Build ${built}: ${status || 'building'}` }), out.review ? (_jsxs(Out, { href: out.review, children: [_jsx(SizableText, { size: "$2", color: "$ink", textDecorationLine: "underline", children: "Open the review" }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] })) : null] })) : null, error ? (_jsx(SizableText, { size: "$2", color: "$red10", role: "alert", children: error })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { variant: "outline", size: "sm", onPress: onClose, children: phase === 'shipped' || phase === 'declared' ? 'Done' : 'Cancel' }), phase === 'ask' || phase === 'failed' || phase === 'sending' ? (_jsx(Button, { size: "sm", disabled: phase === 'sending', onPress: () => void send(), children: phase === 'sending' ? 'Adding…' : 'Add to project' })) : host.admin && (phase === 'declared' || phase === 'shipping') ? (_jsx(Button, { size: "sm", disabled: !green || phase === 'shipping', onPress: () => void ship(), children: phase === 'shipping' ? 'Shipping…' : green ? 'Ship to main' : 'Waiting for the build' })) : null] })] }) }));
109
+ : out.live && green
110
+ ? `Build ${built} succeeded — it is live at the project’s address.`
111
+ : `Build ${built}: ${status || 'building'}` }), out.review ? (_jsxs(Out, { href: out.review, children: [_jsx(SizableText, { size: "$2", color: "$ink", textDecorationLine: "underline", children: "Open the review" }), _jsx(ExternalLink, { size: 12, opacity: 0.6 })] })) : null] })) : null, error ? (_jsx(SizableText, { size: "$2", color: "$red10", role: "alert", children: error })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { variant: "outline", size: "sm", onPress: onClose, children: phase === 'shipped' || phase === 'declared' ? 'Done' : 'Cancel' }), phase === 'ask' || phase === 'failed' || phase === 'sending' ? (_jsx(Button, { size: "sm", disabled: phase === 'sending', onPress: () => void send(), children: phase === 'sending' ? 'Adding…' : 'Add to project' })) : host.admin && !out?.live && (phase === 'declared' || phase === 'shipping') ? (_jsx(Button, { size: "sm", disabled: !green || phase === 'shipping', onPress: () => void ship(), children: phase === 'shipping' ? 'Shipping…' : green ? 'Ship to main' : 'Waiting for the build' })) : null] })] }) }));
95
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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.",
@@ -10,7 +10,7 @@
10
10
  "devDependencies": {
11
11
  "@hanzo/font": "1.9.3",
12
12
  "@hanzo/gui": "8.3.5",
13
- "@hanzo/iam": "0.21.18",
13
+ "@hanzo/iam": "0.21.21",
14
14
  "@hanzo/ui": "8.27.16",
15
15
  "@hanzo/vite": "0.1.4",
16
16
  "@hanzo/voice": "0.1.8",