@hanzo/build 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +205 -0
- package/LICENSE-MIT +21 -0
- package/NOTICE +37 -0
- package/README.md +120 -0
- package/lib/account.d.ts +11 -0
- package/lib/account.js +26 -0
- package/lib/api/call.d.ts +38 -0
- package/lib/api/call.js +75 -0
- package/lib/api/coding.d.ts +56 -0
- package/lib/api/coding.js +58 -0
- package/lib/api/git.d.ts +27 -0
- package/lib/api/git.js +39 -0
- package/lib/api/github.d.ts +78 -0
- package/lib/api/github.js +73 -0
- package/lib/api/models.d.ts +17 -0
- package/lib/api/models.js +28 -0
- package/lib/api/places.d.ts +25 -0
- package/lib/api/places.js +31 -0
- package/lib/api/platform.d.ts +59 -0
- package/lib/api/platform.js +69 -0
- package/lib/api/projects.d.ts +52 -0
- package/lib/api/projects.js +97 -0
- package/lib/api/sessions.d.ts +74 -0
- package/lib/api/sessions.js +120 -0
- package/lib/api/sse.d.ts +24 -0
- package/lib/api/sse.js +68 -0
- package/lib/api/turn.d.ts +51 -0
- package/lib/api/turn.js +121 -0
- package/lib/api/verdict.d.ts +12 -0
- package/lib/api/verdict.js +20 -0
- package/lib/builder.d.ts +5 -0
- package/lib/builder.js +81 -0
- package/lib/data.d.ts +42 -0
- package/lib/data.js +178 -0
- package/lib/host.d.ts +57 -0
- package/lib/host.js +26 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +10 -0
- package/lib/landing.d.ts +13 -0
- package/lib/landing.js +132 -0
- package/lib/out.d.ts +10 -0
- package/lib/out.js +4 -0
- package/lib/project.d.ts +3 -0
- package/lib/project.js +250 -0
- package/lib/publish.d.ts +15 -0
- package/lib/publish.js +94 -0
- package/lib/route.d.ts +30 -0
- package/lib/route.js +28 -0
- package/lib/run.d.ts +3 -0
- package/lib/run.js +67 -0
- package/lib/shelf.d.ts +2 -0
- package/lib/shelf.js +60 -0
- package/lib/tools.d.ts +25 -0
- package/lib/tools.js +64 -0
- package/lib/voice.d.ts +17 -0
- package/lib/voice.js +86 -0
- package/package.json +70 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs, as the platform records them: one session per run.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/agent/sessions?kind=&project=&status=&limit= newest first
|
|
5
|
+
* GET /v1/agent/sessions/{id} + the 50 most recent events
|
|
6
|
+
* POST /v1/agent/sessions/{id}/message {message} steer a running run
|
|
7
|
+
* POST /v1/agent/sessions/{id}/stop {message} end it, work kept
|
|
8
|
+
* GET /v1/agent/sessions/stream?root={id} SSE: `session` and `event` frames
|
|
9
|
+
*
|
|
10
|
+
* The stream is best-effort by the platform's own statement: a subscriber that
|
|
11
|
+
* falls 256 frames behind is dropped. So `watch` reconnects, and every
|
|
12
|
+
* reconnect is reported, which is the caller's cue to re-read the detail.
|
|
13
|
+
*/
|
|
14
|
+
import { type Target } from './call.ts';
|
|
15
|
+
export type Status = 'running' | 'paused' | 'done' | 'error' | 'stopped' | string;
|
|
16
|
+
export interface Session {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
status: Status;
|
|
20
|
+
agent: string;
|
|
21
|
+
actor: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
project: string;
|
|
24
|
+
target: string;
|
|
25
|
+
/** What sort of run: 'coding' for a coding run. */
|
|
26
|
+
kind: string;
|
|
27
|
+
/** The branch the run started from, or ''. */
|
|
28
|
+
base: string;
|
|
29
|
+
/** The branch the run pushes to — the only one it may write. */
|
|
30
|
+
branch: string;
|
|
31
|
+
/** 'sandbox', or the id of the org's machine it runs on. */
|
|
32
|
+
environment: string;
|
|
33
|
+
/** The pull request the run proposed, or ''. */
|
|
34
|
+
pr: string;
|
|
35
|
+
events: number;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
endedAt: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Event {
|
|
41
|
+
id: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
seq: number;
|
|
44
|
+
kind: string;
|
|
45
|
+
actor: string;
|
|
46
|
+
payload: unknown;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
}
|
|
49
|
+
export interface Detail extends Session {
|
|
50
|
+
recent: Event[];
|
|
51
|
+
}
|
|
52
|
+
export declare function session(raw: unknown): Session;
|
|
53
|
+
export declare function event(raw: unknown): Event;
|
|
54
|
+
export interface ListQuery {
|
|
55
|
+
kind?: string;
|
|
56
|
+
project?: string;
|
|
57
|
+
status?: string;
|
|
58
|
+
limit?: number;
|
|
59
|
+
}
|
|
60
|
+
export declare function list(t: Target, q?: ListQuery): Promise<Session[]>;
|
|
61
|
+
export declare function get(t: Target, id: string): Promise<Detail>;
|
|
62
|
+
export declare function message(t: Target, id: string, text: string): Promise<void>;
|
|
63
|
+
export declare function stop(t: Target, id: string, why?: string): Promise<void>;
|
|
64
|
+
export interface Watch {
|
|
65
|
+
session?: (s: Session) => void;
|
|
66
|
+
event?: (e: Event) => void;
|
|
67
|
+
/** Each (re)connect. The first is 0; a later one means frames may have been missed. */
|
|
68
|
+
open?: (attempt: number) => void;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Follow one run's tree until `signal` aborts. Reconnects with a capped backoff;
|
|
72
|
+
* a refusal (401/403) ends it, because retrying a no is not recovery.
|
|
73
|
+
*/
|
|
74
|
+
export declare function watch(t: Target, root: string, on: Watch, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs, as the platform records them: one session per run.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/agent/sessions?kind=&project=&status=&limit= newest first
|
|
5
|
+
* GET /v1/agent/sessions/{id} + the 50 most recent events
|
|
6
|
+
* POST /v1/agent/sessions/{id}/message {message} steer a running run
|
|
7
|
+
* POST /v1/agent/sessions/{id}/stop {message} end it, work kept
|
|
8
|
+
* GET /v1/agent/sessions/stream?root={id} SSE: `session` and `event` frames
|
|
9
|
+
*
|
|
10
|
+
* The stream is best-effort by the platform's own statement: a subscriber that
|
|
11
|
+
* falls 256 frames behind is dropped. So `watch` reconnects, and every
|
|
12
|
+
* reconnect is reported, which is the caller's cue to re-read the detail.
|
|
13
|
+
*/
|
|
14
|
+
import { call, headers, query, Refusal, seg } from './call.js';
|
|
15
|
+
import { read } from './sse.js';
|
|
16
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
17
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
18
|
+
const obj = (v) => v && typeof v === 'object' ? v : {};
|
|
19
|
+
export function session(raw) {
|
|
20
|
+
const s = obj(raw);
|
|
21
|
+
return {
|
|
22
|
+
id: str(s.id),
|
|
23
|
+
title: str(s.title),
|
|
24
|
+
status: str(s.status),
|
|
25
|
+
agent: str(s.agent),
|
|
26
|
+
actor: str(s.actor),
|
|
27
|
+
repo: str(s.repo),
|
|
28
|
+
project: str(s.project),
|
|
29
|
+
target: str(s.target),
|
|
30
|
+
kind: str(s.kind),
|
|
31
|
+
base: str(s.base),
|
|
32
|
+
branch: str(s.branch),
|
|
33
|
+
environment: str(s.environment),
|
|
34
|
+
pr: str(s.pr),
|
|
35
|
+
events: num(s.events),
|
|
36
|
+
createdAt: str(s.createdAt),
|
|
37
|
+
updatedAt: str(s.updatedAt),
|
|
38
|
+
endedAt: str(s.endedAt),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function event(raw) {
|
|
42
|
+
const e = obj(raw);
|
|
43
|
+
return {
|
|
44
|
+
id: str(e.id),
|
|
45
|
+
sessionId: str(e.sessionId),
|
|
46
|
+
seq: num(e.seq),
|
|
47
|
+
kind: str(e.kind),
|
|
48
|
+
actor: str(e.actor),
|
|
49
|
+
payload: e.payload,
|
|
50
|
+
createdAt: str(e.createdAt),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function list(t, q = {}) {
|
|
54
|
+
const raw = obj(await call(t, 'GET', `/v1/agent/sessions${query({ ...q })}`));
|
|
55
|
+
return (Array.isArray(raw.sessions) ? raw.sessions : []).map(session).filter((s) => s.id);
|
|
56
|
+
}
|
|
57
|
+
export async function get(t, id) {
|
|
58
|
+
const raw = obj(await call(t, 'GET', `/v1/agent/sessions/${seg(id)}`));
|
|
59
|
+
return {
|
|
60
|
+
...session(raw),
|
|
61
|
+
recent: (Array.isArray(raw.recentEvents) ? raw.recentEvents : []).map(event),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function message(t, id, text) {
|
|
65
|
+
const m = text.trim();
|
|
66
|
+
if (!m)
|
|
67
|
+
throw new Refusal(400, 'Say something to the run');
|
|
68
|
+
await call(t, 'POST', `/v1/agent/sessions/${seg(id)}/message`, { message: m });
|
|
69
|
+
}
|
|
70
|
+
export async function stop(t, id, why = 'Stopped from the builder') {
|
|
71
|
+
await call(t, 'POST', `/v1/agent/sessions/${seg(id)}/stop`, { message: why });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Follow one run's tree until `signal` aborts. Reconnects with a capped backoff;
|
|
75
|
+
* a refusal (401/403) ends it, because retrying a no is not recovery.
|
|
76
|
+
*/
|
|
77
|
+
export async function watch(t, root, on, signal) {
|
|
78
|
+
let opened = 0;
|
|
79
|
+
let fails = 0;
|
|
80
|
+
while (!signal.aborted) {
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${t.api}/v1/agent/sessions/stream${query({ root })}`, {
|
|
83
|
+
headers: headers(t, { Accept: 'text/event-stream' }),
|
|
84
|
+
cache: 'no-store',
|
|
85
|
+
signal,
|
|
86
|
+
});
|
|
87
|
+
if (res.status === 401 || res.status === 403)
|
|
88
|
+
throw new Refusal(res.status, 'This account cannot follow this run');
|
|
89
|
+
if (!res.ok || !res.body)
|
|
90
|
+
throw new Error(`stream answered ${res.status}`);
|
|
91
|
+
on.open?.(opened++);
|
|
92
|
+
fails = 0;
|
|
93
|
+
await read(res.body, (f) => {
|
|
94
|
+
let data;
|
|
95
|
+
try {
|
|
96
|
+
data = JSON.parse(f.data);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (f.event === 'session')
|
|
102
|
+
on.session?.(session(data));
|
|
103
|
+
else if (f.event === 'event')
|
|
104
|
+
on.event?.(event(data));
|
|
105
|
+
}, signal);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e instanceof Refusal)
|
|
109
|
+
throw e;
|
|
110
|
+
if (signal.aborted)
|
|
111
|
+
return;
|
|
112
|
+
fails += 1;
|
|
113
|
+
}
|
|
114
|
+
const wait = Math.min(15000, 1000 * 2 ** Math.min(fails, 4));
|
|
115
|
+
await new Promise((done) => {
|
|
116
|
+
const t = setTimeout(done, wait);
|
|
117
|
+
signal.addEventListener('abort', () => (clearTimeout(t), done()), { once: true });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
package/lib/api/sse.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events, read off a fetch body.
|
|
3
|
+
*
|
|
4
|
+
* `EventSource` cannot send an Authorization header, and the session stream
|
|
5
|
+
* refuses a request without a principal, so the stream is read as a plain
|
|
6
|
+
* response body and parsed here. The parser is pure: bytes in, frames and the
|
|
7
|
+
* unfinished tail out, so it is tested without a network.
|
|
8
|
+
*
|
|
9
|
+
* Per the spec a frame ends at a blank line; `event:` names it (default
|
|
10
|
+
* `message`), `data:` lines join with a newline, and a line starting `:` is a
|
|
11
|
+
* comment — the platform's 25-second `: ping` keep-alive.
|
|
12
|
+
*/
|
|
13
|
+
export interface Frame {
|
|
14
|
+
event: string;
|
|
15
|
+
data: string;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
/** Split `buffer` into complete frames and the tail that has not ended yet. */
|
|
19
|
+
export declare function parse(buffer: string): {
|
|
20
|
+
frames: Frame[];
|
|
21
|
+
rest: string;
|
|
22
|
+
};
|
|
23
|
+
/** Read `body` to its end, calling `on` for each frame. */
|
|
24
|
+
export declare function read(body: ReadableStream<Uint8Array>, on: (f: Frame) => void, signal?: AbortSignal): Promise<void>;
|
package/lib/api/sse.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events, read off a fetch body.
|
|
3
|
+
*
|
|
4
|
+
* `EventSource` cannot send an Authorization header, and the session stream
|
|
5
|
+
* refuses a request without a principal, so the stream is read as a plain
|
|
6
|
+
* response body and parsed here. The parser is pure: bytes in, frames and the
|
|
7
|
+
* unfinished tail out, so it is tested without a network.
|
|
8
|
+
*
|
|
9
|
+
* Per the spec a frame ends at a blank line; `event:` names it (default
|
|
10
|
+
* `message`), `data:` lines join with a newline, and a line starting `:` is a
|
|
11
|
+
* comment — the platform's 25-second `: ping` keep-alive.
|
|
12
|
+
*/
|
|
13
|
+
/** Split `buffer` into complete frames and the tail that has not ended yet. */
|
|
14
|
+
export function parse(buffer) {
|
|
15
|
+
const text = buffer.replace(/\r\n?/g, '\n');
|
|
16
|
+
const frames = [];
|
|
17
|
+
let start = 0;
|
|
18
|
+
for (;;) {
|
|
19
|
+
const end = text.indexOf('\n\n', start);
|
|
20
|
+
if (end === -1)
|
|
21
|
+
break;
|
|
22
|
+
const block = text.slice(start, end);
|
|
23
|
+
start = end + 2;
|
|
24
|
+
let event = 'message';
|
|
25
|
+
let id = '';
|
|
26
|
+
const data = [];
|
|
27
|
+
for (const line of block.split('\n')) {
|
|
28
|
+
if (!line || line.startsWith(':'))
|
|
29
|
+
continue;
|
|
30
|
+
const colon = line.indexOf(':');
|
|
31
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
32
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
33
|
+
if (value.startsWith(' '))
|
|
34
|
+
value = value.slice(1);
|
|
35
|
+
if (field === 'event')
|
|
36
|
+
event = value;
|
|
37
|
+
else if (field === 'data')
|
|
38
|
+
data.push(value);
|
|
39
|
+
else if (field === 'id')
|
|
40
|
+
id = value;
|
|
41
|
+
}
|
|
42
|
+
if (data.length)
|
|
43
|
+
frames.push({ event, data: data.join('\n'), id });
|
|
44
|
+
}
|
|
45
|
+
return { frames, rest: text.slice(start) };
|
|
46
|
+
}
|
|
47
|
+
/** Read `body` to its end, calling `on` for each frame. */
|
|
48
|
+
export async function read(body, on, signal) {
|
|
49
|
+
const reader = body.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
let rest = '';
|
|
52
|
+
const stop = () => void reader.cancel().catch(() => { });
|
|
53
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
54
|
+
try {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { value, done } = await reader.read();
|
|
57
|
+
if (done)
|
|
58
|
+
break;
|
|
59
|
+
const out = parse(rest + decoder.decode(value, { stream: true }));
|
|
60
|
+
rest = out.rest;
|
|
61
|
+
for (const f of out.frames)
|
|
62
|
+
on(f);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
signal?.removeEventListener('abort', stop);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run's events SAY, as text a person reads.
|
|
3
|
+
*
|
|
4
|
+
* A curated projection, never a dump: an event carries the machinery a run
|
|
5
|
+
* happened on — hosts, paths, whole file bodies — and printing it would put
|
|
6
|
+
* someone's filesystem in front of anyone who can read the run. Each kind gets a
|
|
7
|
+
* sentence built from the few fields that describe the outcome; the rest is
|
|
8
|
+
* dropped by omission, because a projection cannot leak a field it never reads.
|
|
9
|
+
*
|
|
10
|
+
* Every string here is rendered as TEXT by the transcript. Nothing is HTML.
|
|
11
|
+
*
|
|
12
|
+
* The coding plane's vocabulary (apps/coding): `status` for a lifecycle move
|
|
13
|
+
* (started, routed, done, error, stopped, paused), `tool-call` for a step,
|
|
14
|
+
* `log` for a free line. A steering message a person sent is a `message`.
|
|
15
|
+
*/
|
|
16
|
+
import type { Event } from './sessions.ts';
|
|
17
|
+
/** The payload as an object. A live frame may carry it as a JSON string. */
|
|
18
|
+
export declare function decode(payload: unknown): Record<string, unknown> | string | null;
|
|
19
|
+
/** One event's sentence, or '' when it says nothing a person needs. */
|
|
20
|
+
export declare function said(e: Pick<Event, 'kind' | 'payload'>): string;
|
|
21
|
+
export interface Outcome {
|
|
22
|
+
/** The last lifecycle status the run narrated, or ''. */
|
|
23
|
+
status: string;
|
|
24
|
+
/**
|
|
25
|
+
* That the pull request could not be opened, as narrated, or ''. Shown as a
|
|
26
|
+
* fact only — its text comes from an event any member can write, so it is
|
|
27
|
+
* never put on screen as the reason.
|
|
28
|
+
*/
|
|
29
|
+
problem: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What the run narrated about how it ended, latest winning. Status text only:
|
|
33
|
+
* the branch and the pull request are read from the run's record (`pull`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function outcome(events: Pick<Event, 'kind' | 'payload' | 'seq'>[]): Outcome;
|
|
36
|
+
/** The turns, ordered and deduped: a recorded read and the live feed overlap. */
|
|
37
|
+
export declare function merge<T extends Pick<Event, 'id' | 'seq'>>(...lists: T[][]): T[];
|
|
38
|
+
/** The actor, short enough to label a block: `hanzo/2d4d67ab`, not the whole subject. */
|
|
39
|
+
export declare function who(actor: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* A pull request address the builder will draw as a link: https, on GitHub or
|
|
42
|
+
* the platform's own git, shaped like a pull request, and IN THE RUN'S OWN
|
|
43
|
+
* REPOSITORY (`repo`, `owner/name`). Anything else is ''. Read from the run's
|
|
44
|
+
* record, which the coding service writes — never from an event, which any
|
|
45
|
+
* member of the org can append — and bound to the repository, because a record
|
|
46
|
+
* can be moved into a project it did not work on.
|
|
47
|
+
*/
|
|
48
|
+
export declare function pull(url: string, repo: string): {
|
|
49
|
+
href: string;
|
|
50
|
+
label: string;
|
|
51
|
+
};
|
package/lib/api/turn.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
2
|
+
/** The payload as an object. A live frame may carry it as a JSON string. */
|
|
3
|
+
export function decode(payload) {
|
|
4
|
+
if (payload && typeof payload === 'object')
|
|
5
|
+
return payload;
|
|
6
|
+
if (typeof payload !== 'string')
|
|
7
|
+
return null;
|
|
8
|
+
const text = payload.trim();
|
|
9
|
+
if (!text.startsWith('{'))
|
|
10
|
+
return payload;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(text);
|
|
13
|
+
if (parsed && typeof parsed === 'object')
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* prose that opens with a brace */
|
|
18
|
+
}
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
/** A file's name, never the host path it sat at. */
|
|
22
|
+
const base = (path) => {
|
|
23
|
+
const cut = path.replace(/\/+$/, '');
|
|
24
|
+
return cut.slice(cut.lastIndexOf('/') + 1);
|
|
25
|
+
};
|
|
26
|
+
function status(b) {
|
|
27
|
+
const branch = str(b.branch);
|
|
28
|
+
switch (str(b.status)) {
|
|
29
|
+
case 'started':
|
|
30
|
+
return branch ? `Started on ${branch}` : 'Started';
|
|
31
|
+
case 'routed':
|
|
32
|
+
return 'Sent to a machine';
|
|
33
|
+
case 'done': {
|
|
34
|
+
if (b.changed === false)
|
|
35
|
+
return 'Done — nothing to change';
|
|
36
|
+
const pr = str(b.pr);
|
|
37
|
+
const tail = str(b.prError) ? ' — the pull request could not be opened' : pr ? ` — ${pr}` : '';
|
|
38
|
+
return branch ? `Pushed ${branch}${tail}` : `Done${tail}`;
|
|
39
|
+
}
|
|
40
|
+
case 'error':
|
|
41
|
+
return str(b.error) || 'The run hit an error';
|
|
42
|
+
case 'stopped':
|
|
43
|
+
return branch && b.changed ? `Stopped — work kept on ${branch}` : 'Stopped';
|
|
44
|
+
case 'paused':
|
|
45
|
+
return 'Paused';
|
|
46
|
+
}
|
|
47
|
+
return str(b.status);
|
|
48
|
+
}
|
|
49
|
+
/** One event's sentence, or '' when it says nothing a person needs. */
|
|
50
|
+
export function said(e) {
|
|
51
|
+
const body = decode(e.payload);
|
|
52
|
+
if (typeof body === 'string')
|
|
53
|
+
return body;
|
|
54
|
+
if (!body)
|
|
55
|
+
return '';
|
|
56
|
+
if (e.kind === 'status')
|
|
57
|
+
return status(body);
|
|
58
|
+
for (const key of ['message', 'text', 'content', 'result', 'command']) {
|
|
59
|
+
const v = body[key];
|
|
60
|
+
if (typeof v === 'string' && v)
|
|
61
|
+
return v;
|
|
62
|
+
}
|
|
63
|
+
if (str(body.type) === 'done' && Array.isArray(body.changed)) {
|
|
64
|
+
const names = body.changed.map((f) => base(str(f))).filter(Boolean);
|
|
65
|
+
return names.length === 0
|
|
66
|
+
? 'Done — no files changed'
|
|
67
|
+
: names.length <= 3
|
|
68
|
+
? `Done — changed ${names.join(', ')}`
|
|
69
|
+
: `Done — changed ${names.length} files`;
|
|
70
|
+
}
|
|
71
|
+
if (str(body.type) === 'error')
|
|
72
|
+
return str(body.error) || 'The run hit an error';
|
|
73
|
+
return str(body.step) || str(body.name);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What the run narrated about how it ended, latest winning. Status text only:
|
|
77
|
+
* the branch and the pull request are read from the run's record (`pull`).
|
|
78
|
+
*/
|
|
79
|
+
export function outcome(events) {
|
|
80
|
+
const out = { status: '', problem: '' };
|
|
81
|
+
for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
|
|
82
|
+
if (e.kind !== 'status')
|
|
83
|
+
continue;
|
|
84
|
+
const b = decode(e.payload);
|
|
85
|
+
if (!b || typeof b === 'string')
|
|
86
|
+
continue;
|
|
87
|
+
out.status = str(b.status) || out.status;
|
|
88
|
+
out.problem = str(b.prError) || out.problem;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** The turns, ordered and deduped: a recorded read and the live feed overlap. */
|
|
93
|
+
export function merge(...lists) {
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
for (const list of lists)
|
|
96
|
+
for (const e of list)
|
|
97
|
+
seen.set(e.seq ? `s${e.seq}` : `i${e.id}`, e);
|
|
98
|
+
return [...seen.values()].sort((a, b) => a.seq - b.seq);
|
|
99
|
+
}
|
|
100
|
+
/** The actor, short enough to label a block: `hanzo/2d4d67ab`, not the whole subject. */
|
|
101
|
+
export function who(actor) {
|
|
102
|
+
const cut = actor.indexOf('/');
|
|
103
|
+
if (cut === -1)
|
|
104
|
+
return actor.split('-')[0] || actor;
|
|
105
|
+
const head = actor.slice(cut + 1).split('-')[0] ?? '';
|
|
106
|
+
return head ? `${actor.slice(0, cut)}/${head}` : actor;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A pull request address the builder will draw as a link: https, on GitHub or
|
|
110
|
+
* the platform's own git, shaped like a pull request, and IN THE RUN'S OWN
|
|
111
|
+
* REPOSITORY (`repo`, `owner/name`). Anything else is ''. Read from the run's
|
|
112
|
+
* record, which the coding service writes — never from an event, which any
|
|
113
|
+
* member of the org can append — and bound to the repository, because a record
|
|
114
|
+
* can be moved into a project it did not work on.
|
|
115
|
+
*/
|
|
116
|
+
export function pull(url, repo) {
|
|
117
|
+
const m = /^https:\/\/(?:github\.com|git\.hanzo\.ai)\/([\w.-]+)\/([\w.-]+)\/pulls?\/(\d+)\/?$/.exec(url);
|
|
118
|
+
if (!m || !repo || `${m[1]}/${m[2]}`.toLowerCase() !== repo.toLowerCase())
|
|
119
|
+
return { href: '', label: '' };
|
|
120
|
+
return { href: url, label: `#${m[3]}` };
|
|
121
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's verdict on what a run said, recorded on the platform's own event
|
|
3
|
+
* bus in the org it was said in.
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/event {type: 'track', event: 'build.verdict', sessionId, properties}
|
|
6
|
+
*
|
|
7
|
+
* The receipt says whether it landed; a verdict that did not is reported, not
|
|
8
|
+
* shown as recorded.
|
|
9
|
+
*/
|
|
10
|
+
import { type Target } from './call.ts';
|
|
11
|
+
export type Verdict = 'up' | 'down' | null;
|
|
12
|
+
export declare function verdict(t: Target, session: string, project: string, v: Verdict): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's verdict on what a run said, recorded on the platform's own event
|
|
3
|
+
* bus in the org it was said in.
|
|
4
|
+
*
|
|
5
|
+
* POST /v1/event {type: 'track', event: 'build.verdict', sessionId, properties}
|
|
6
|
+
*
|
|
7
|
+
* The receipt says whether it landed; a verdict that did not is reported, not
|
|
8
|
+
* shown as recorded.
|
|
9
|
+
*/
|
|
10
|
+
import { call } from './call.js';
|
|
11
|
+
export async function verdict(t, session, project, v) {
|
|
12
|
+
const out = await call(t, 'POST', '/v1/event', {
|
|
13
|
+
type: 'track',
|
|
14
|
+
event: 'build.verdict',
|
|
15
|
+
sessionId: session,
|
|
16
|
+
properties: { verdict: v ?? 'cleared', project },
|
|
17
|
+
});
|
|
18
|
+
if (!out || !out.accepted)
|
|
19
|
+
throw new Error('The verdict was not recorded');
|
|
20
|
+
}
|
package/lib/builder.d.ts
ADDED
package/lib/builder.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The builder: the rail, and whichever pane the address names.
|
|
4
|
+
*
|
|
5
|
+
* / New — the empty state, a composer over the repo it works on
|
|
6
|
+
* /sess_… one run, live
|
|
7
|
+
* /-/artifacts what this org has built
|
|
8
|
+
* /-/templates the public starters
|
|
9
|
+
* /<slug> a project's workspace, in this same window
|
|
10
|
+
*
|
|
11
|
+
* The rail is the sessions rail every Hanzo surface shares: New, the builder's
|
|
12
|
+
* places, the org's coding runs newest first with a live status dot, and the
|
|
13
|
+
* account. Its collapse is an explicit toggle kept per browser.
|
|
14
|
+
*
|
|
15
|
+
* A project's workspace takes the whole window — it has its own chat column,
|
|
16
|
+
* bar and panes — and its mark leads back here.
|
|
17
|
+
*/
|
|
18
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
19
|
+
import { Blocks, BookOpen, Cpu, LayoutTemplate, Menu, Puzzle } from '@hanzogui/lucide-icons-2';
|
|
20
|
+
import { Button } from '@hanzo/ui';
|
|
21
|
+
import { SessionRail } from '@hanzo/ui/chat';
|
|
22
|
+
import { HanzoMark } from '@hanzo/ui/product';
|
|
23
|
+
import { useMemo, useState } from 'react';
|
|
24
|
+
import { useKept, useRecents } from './data.js';
|
|
25
|
+
import { HostProvider, useHost, useTarget } from './host.js';
|
|
26
|
+
import { Landing } from './landing.js';
|
|
27
|
+
import { Project } from './project.js';
|
|
28
|
+
import { path, route } from './route.js';
|
|
29
|
+
import { Run } from './run.js';
|
|
30
|
+
import { Artifacts, Templates } from './shelf.js';
|
|
31
|
+
import { Account, Find } from './account.js';
|
|
32
|
+
const DOTS = {
|
|
33
|
+
running: 'running',
|
|
34
|
+
paused: 'paused',
|
|
35
|
+
done: 'done',
|
|
36
|
+
stopped: 'stopped',
|
|
37
|
+
error: 'error',
|
|
38
|
+
};
|
|
39
|
+
function Shell() {
|
|
40
|
+
const host = useHost();
|
|
41
|
+
const t = useTarget();
|
|
42
|
+
const r = route(host.path);
|
|
43
|
+
const recents = useRecents(t, Boolean(host.person));
|
|
44
|
+
const [collapsed, setCollapsed] = useKept('hanzo.build.rail', false);
|
|
45
|
+
const [drawer, setDrawer] = useState(false);
|
|
46
|
+
const [order, setOrder] = useState('newest');
|
|
47
|
+
const [account, setAccount] = useState(false);
|
|
48
|
+
const [finding, setFinding] = useState(false);
|
|
49
|
+
const go = (p) => {
|
|
50
|
+
setDrawer(false);
|
|
51
|
+
host.go(p);
|
|
52
|
+
};
|
|
53
|
+
const rows = useMemo(() => {
|
|
54
|
+
const list = recents.value.map((s) => ({ id: s.id, title: s.title || 'Untitled run', status: DOTS[s.status] ?? 'idle' }));
|
|
55
|
+
if (order === 'running')
|
|
56
|
+
list.sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running'));
|
|
57
|
+
return list;
|
|
58
|
+
}, [recents.value, order]);
|
|
59
|
+
const screen = r.kind === 'screen' ? r.screen : '';
|
|
60
|
+
const links = [
|
|
61
|
+
{ id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
|
|
62
|
+
{ id: 'customize', label: 'Customize', icon: _jsx(Puzzle, { size: 16 }), onPress: () => host.open(host.links.customize) },
|
|
63
|
+
];
|
|
64
|
+
const more = [
|
|
65
|
+
{ id: 'templates', label: 'Templates', icon: _jsx(LayoutTemplate, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'templates' })), active: screen === 'templates' },
|
|
66
|
+
{ id: 'machines', label: 'Machines', icon: _jsx(Cpu, { size: 16 }), onPress: () => host.open(`${host.links.home.replace(/\/+$/, '')}/platform/computers`) },
|
|
67
|
+
{ id: 'docs', label: 'Docs', icon: _jsx(BookOpen, { size: 16 }), onPress: () => window.open('https://docs.hanzo.ai/docs/dev', '_blank', 'noopener,noreferrer') },
|
|
68
|
+
];
|
|
69
|
+
return (_jsxs(XStack, { flex: 1, minH: 0, minW: 0, bg: "$background", children: [_jsx(SessionRail, { onNew: () => go(''), fresh: r.kind === 'new', links: links, more: more, recents: rows, active: r.kind === 'run' ? r.id : null, onOpen: (id) => go(id), onSort: () => setOrder(order === 'newest' ? 'running' : 'newest'), sortLabel: order === 'newest' ? 'Show running first' : 'Show newest first', empty: _jsx(SizableText, { size: "$1", color: "$soft", px: "$2", children: !host.person ? 'Sign in to see your runs.' : recents.error ? recents.error.message : recents.loading ? 'Reading…' : 'No runs yet.' }), account: host.person
|
|
70
|
+
? { name: host.person.email || host.person.name, onPress: () => setAccount(true) }
|
|
71
|
+
: { name: 'Sign in', onPress: () => host.signIn?.() }, onSettings: () => host.open(host.links.settings), onSearch: () => setFinding(true), collapsed: collapsed, onCollapse: setCollapsed, mark: _jsx(HanzoMark, { size: 18 }), open: drawer, onOpenChange: setDrawer, label: "Runs" }), _jsxs(YStack, { flex: 1, minW: 0, minH: 0, position: "relative", children: [_jsx(XStack, { height: 44, px: "$2", items: "center", shrink: 0, $md: { display: 'none' }, children: _jsx(Button, { variant: "ghost", size: "icon-sm", onPress: () => setDrawer(true), "aria-label": "Open runs", children: _jsx(Menu, { size: 18 }) }) }), r.kind === 'run' ? (_jsx(Run, { id: r.id }, r.id)) : r.kind === 'screen' ? (r.screen === 'artifacts' ? _jsx(Artifacts, {}) : _jsx(Templates, {})) : (_jsx(Landing, { onStarted: (id) => { recents.reload(); go(id); } }))] }), _jsx(Account, { open: account, onOpenChange: setAccount }), _jsx(Find, { open: finding, onOpenChange: setFinding, recents: rows, onOpen: (id) => { setFinding(false); go(id); } })] }));
|
|
72
|
+
}
|
|
73
|
+
function Screens() {
|
|
74
|
+
const host = useHost();
|
|
75
|
+
const r = route(host.path);
|
|
76
|
+
return r.kind === 'project' ? _jsx(Project, { slug: r.slug }, r.slug) : _jsx(Shell, {});
|
|
77
|
+
}
|
|
78
|
+
/** The builder. Mount it under a gui root (`<Hanzo>`), in a box with a height. */
|
|
79
|
+
export function Builder({ host }) {
|
|
80
|
+
return (_jsx(HostProvider, { host: host, children: _jsx(YStack, { flex: 1, minH: 0, minW: 0, height: "100%", children: _jsx(Screens, {}) }) }));
|
|
81
|
+
}
|
package/lib/data.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Target } from './api/call.ts';
|
|
2
|
+
import { type Place } from './api/places.ts';
|
|
3
|
+
import { type Project } from './api/projects.ts';
|
|
4
|
+
import { type Detail, type Event, type Session } from './api/sessions.ts';
|
|
5
|
+
export interface Read<T> {
|
|
6
|
+
value: T;
|
|
7
|
+
error: Error | null;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
reload: () => void;
|
|
10
|
+
}
|
|
11
|
+
/** Run `load` whenever `key` changes (and on `reload`), keeping the last good value. */
|
|
12
|
+
export declare function useRead<T>(load: (() => Promise<T>) | null, initial: T, key: unknown[]): Read<T>;
|
|
13
|
+
/**
|
|
14
|
+
* The org's coding runs, newest first, kept current by the org's own session
|
|
15
|
+
* stream: a status that moves moves its dot, and a run started anywhere appears.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useRecents(t: Target, signed: boolean): Read<Session[]>;
|
|
18
|
+
export declare function usePlaces(t: Target, signed: boolean): Read<Place[]>;
|
|
19
|
+
export declare function useProjects(t: Target, signed: boolean): Read<Project[]>;
|
|
20
|
+
/** A value kept in this browser, per key; storage that throws keeps it in memory. */
|
|
21
|
+
export declare function useKept<T>(key: string, initial: T): [T, (v: T) => void];
|
|
22
|
+
export interface RunState {
|
|
23
|
+
detail: Read<Detail | null>;
|
|
24
|
+
/**
|
|
25
|
+
* The run's record, as last read or pushed: status, branch, pull request.
|
|
26
|
+
* The coding service writes it; events are the narration, and any member of
|
|
27
|
+
* the org can append one, so a link or a branch is never taken from them.
|
|
28
|
+
*/
|
|
29
|
+
record: Session | null;
|
|
30
|
+
/** Every turn, recorded and live, in order. */
|
|
31
|
+
events: Event[];
|
|
32
|
+
/** The run's status as last reported by the record or the feed. */
|
|
33
|
+
status: string;
|
|
34
|
+
/** Why the feed stopped, when it was refused. */
|
|
35
|
+
refused: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One run: the recorded detail, then the live feed added to it. A reconnect
|
|
39
|
+
* after a drop re-reads the detail — frames missed while dropped are only in
|
|
40
|
+
* the record. `id` null reads nothing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useRun(t: Target, id: string | null): RunState;
|