@fapost/jig-ui 0.1.0-next.9 → 0.1.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/dist/board/columns.js +29 -0
- package/dist/http/app.js +41 -0
- package/dist/jig/client.js +120 -4
- package/dist/main.js +38 -8
- package/dist/node-version.js +1 -2
- package/dist/registry/instance.js +21 -0
- package/dist/source/checkout.js +19 -1
- package/dist/source/local.js +216 -18
- package/dist/watch/watch.js +56 -0
- package/dist/web/assets/CanvasView-kgt4JQsV.js +1 -0
- package/dist/web/assets/index-CYfTN3Dj.js +6 -0
- package/dist/web/assets/index-hMczOUNS.css +2 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/web/index.html +4 -2
- package/package.json +20 -1
- package/dist/web/assets/index-D4V5dJnw.js +0 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The board's order: what waits for the person first, then what moves, what is done, what is not begun, what is set aside. */
|
|
2
|
+
const COLUMNS = [
|
|
3
|
+
{ id: 'needs-you', title: 'Needs you' },
|
|
4
|
+
{ id: 'in-progress', title: 'In progress' },
|
|
5
|
+
{ id: 'ready', title: 'Ready' },
|
|
6
|
+
{ id: 'backlog', title: 'Backlog' },
|
|
7
|
+
{ id: 'paused', title: 'Paused' },
|
|
8
|
+
];
|
|
9
|
+
/** Where a task stands, from Jig's state alone; the first rule that fits wins. */
|
|
10
|
+
export function columnOf(task) {
|
|
11
|
+
if (task.paused)
|
|
12
|
+
return 'paused';
|
|
13
|
+
if (!task.started)
|
|
14
|
+
return 'backlog';
|
|
15
|
+
if (task.gate === 'waiting' || task.gate === 'changed' || task.autopilot === 'stopped')
|
|
16
|
+
return 'needs-you';
|
|
17
|
+
if (task.status === 'ready')
|
|
18
|
+
return 'ready';
|
|
19
|
+
return 'in-progress';
|
|
20
|
+
}
|
|
21
|
+
/** The column a task sits in, with its title. */
|
|
22
|
+
export function placeOf(task) {
|
|
23
|
+
const id = columnOf(task);
|
|
24
|
+
return { id, title: COLUMNS.find((c) => c.id === id)?.title ?? id };
|
|
25
|
+
}
|
|
26
|
+
/** The board: every column, in a fixed order, its cards in the order Jig listed them. Computed on every read, never stored. */
|
|
27
|
+
export function columns(tasks) {
|
|
28
|
+
return COLUMNS.map((c) => ({ ...c, cards: tasks.filter((t) => columnOf(t) === c.id) }));
|
|
29
|
+
}
|
package/dist/http/app.js
CHANGED
|
@@ -4,6 +4,8 @@ import { isAbsolute, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { serveStatic } from '@hono/node-server/serve-static';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
+
import { streamSSE } from 'hono/streaming';
|
|
8
|
+
import { columns, placeOf } from '../board/columns.js';
|
|
7
9
|
import { RegistryUnreadable } from '../source/project-source.js';
|
|
8
10
|
import { localOnly, tokenAccess } from './access.js';
|
|
9
11
|
/**
|
|
@@ -17,6 +19,8 @@ export function readPackageVersion() {
|
|
|
17
19
|
}
|
|
18
20
|
/** The built page: `dist/web`, next to `dist/http` where this file runs when published. */
|
|
19
21
|
export const DEFAULT_WEB_ROOT = fileURLToPath(new URL('../web', import.meta.url));
|
|
22
|
+
/** How often an idle stream sends a comment, so a closed tab is noticed and its subscription ends. */
|
|
23
|
+
const PING_MS = 30_000;
|
|
20
24
|
const refuse = (c, status, code, message) => c.json({ error: { code, message } }, status);
|
|
21
25
|
/** The path the person typed, absolute; `~/` stands for their home. */
|
|
22
26
|
function absolute(path) {
|
|
@@ -37,6 +41,31 @@ export function createApp({ port, token, source, version = readPackageVersion(),
|
|
|
37
41
|
});
|
|
38
42
|
app.get('/api/instance', (c) => c.json({ version }));
|
|
39
43
|
app.get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }));
|
|
44
|
+
app.get('/api/projects/:id/board', async (c) => {
|
|
45
|
+
const id = c.req.param('id');
|
|
46
|
+
const read = await source.tasks(id);
|
|
47
|
+
if (read === undefined)
|
|
48
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
49
|
+
if (read.state !== 'ready')
|
|
50
|
+
return c.json({ board: read });
|
|
51
|
+
return c.json({ board: { id, state: 'ready', columns: columns(read.tasks), finished: read.finished } });
|
|
52
|
+
});
|
|
53
|
+
app.get('/api/projects/:id/tasks/:task', async (c) => {
|
|
54
|
+
const read = await source.task(c.req.param('id'), c.req.param('task'));
|
|
55
|
+
if (read === undefined)
|
|
56
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
57
|
+
if (read.state !== 'ready')
|
|
58
|
+
return c.json({ task: read });
|
|
59
|
+
if (read.task === null)
|
|
60
|
+
return refuse(c, 404, 'not-found', 'This project has no live task with this id.');
|
|
61
|
+
return c.json({ task: { state: 'ready', ...read.task, column: placeOf(read.task) } });
|
|
62
|
+
});
|
|
63
|
+
app.get('/api/projects/:id/specs', async (c) => {
|
|
64
|
+
const read = await source.specs(c.req.param('id'));
|
|
65
|
+
if (read === undefined)
|
|
66
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
67
|
+
return c.json({ specs: read });
|
|
68
|
+
});
|
|
40
69
|
app.post('/api/projects', async (c) => {
|
|
41
70
|
const body = await c.req.json().catch(() => undefined);
|
|
42
71
|
const path = absolute(body?.path);
|
|
@@ -53,6 +82,18 @@ export function createApp({ port, token, source, version = readPackageVersion(),
|
|
|
53
82
|
}
|
|
54
83
|
return c.body(null, 204);
|
|
55
84
|
});
|
|
85
|
+
// A signal, not data: the page re-fetches what it shows (adr-20261014-project-source-port-keeps-hosting-open).
|
|
86
|
+
app.get('/api/events', (c) => streamSSE(c, async (stream) => {
|
|
87
|
+
const unsubscribe = source.subscribe(({ id, revision }) => {
|
|
88
|
+
void stream.writeSSE({ event: 'changed', data: JSON.stringify({ project: id, revision }) });
|
|
89
|
+
});
|
|
90
|
+
stream.onAbort(unsubscribe);
|
|
91
|
+
while (!stream.aborted) {
|
|
92
|
+
await stream.sleep(PING_MS);
|
|
93
|
+
if (!stream.aborted)
|
|
94
|
+
await stream.write(': ping\n\n');
|
|
95
|
+
}
|
|
96
|
+
}));
|
|
56
97
|
app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
|
|
57
98
|
if (existsSync(join(webRoot, 'index.html'))) {
|
|
58
99
|
app.use('/*', serveStatic({ root: webRoot }));
|
package/dist/jig/client.js
CHANGED
|
@@ -7,6 +7,16 @@ const run = promisify(execFile);
|
|
|
7
7
|
export const MIN_JIG_VERSION = '0.29.0';
|
|
8
8
|
const SCRIPT = join('.ai', 'scripts', 'jig');
|
|
9
9
|
const TIMEOUT_MS = 10_000;
|
|
10
|
+
/** The directory whose changes may change what Jig reports: the main checkout's `.ai/`. */
|
|
11
|
+
export const watchDir = (root) => join(root, '.ai');
|
|
12
|
+
/**
|
|
13
|
+
* True for a path under `.ai/` (with `/` separators) that Jig writes while it only reads — its
|
|
14
|
+
* records of what it is doing under `runtime/` — so a change there is no sign of a change, and
|
|
15
|
+
* watching it would make every read trigger the next. `runtime/revision/` is where the clone's
|
|
16
|
+
* revision rises on each Jig write, and stays a sign. Jig names no such path in its contract
|
|
17
|
+
* (Jig 0.29.0, found by watching which files its reads touch).
|
|
18
|
+
*/
|
|
19
|
+
export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('runtime/')) && !path.startsWith('runtime/revision');
|
|
10
20
|
/**
|
|
11
21
|
* The bash that runs `.ai/scripts/jig`. On Windows it is Git's, found next to git itself
|
|
12
22
|
* (`<git>/mingw64/libexec/git-core` → `<git>/bin/bash.exe`): a `bash` on PATH may be WSL's.
|
|
@@ -117,13 +127,119 @@ export async function readStatus(root, platform = process.platform) {
|
|
|
117
127
|
};
|
|
118
128
|
}
|
|
119
129
|
const STATUSES = new Set(['active', 'ready', 'consolidated', 'abandoned']);
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
/** Jig's action ids this Jig UI knows; Jig may add ids without raising `schema`, and a consumer ignores those. */
|
|
131
|
+
const ACTIONS = new Set(['start', 'pause', 'resume', 'approve-gate', 'answer-stop', 'set-class', 'ship', 'close', 'abandon', 'reopen']);
|
|
132
|
+
const nullableStrings = (v, keys) => keys.every((k) => isNullableString(v[k]));
|
|
133
|
+
const isGate = (v) => v === null || (isObject(v) && isString(v.state) && isNullableString(v.by));
|
|
134
|
+
const isAutopilot = (v) => v === null || (isObject(v) && isString(v.state) && nullableStrings(v, ['mode', 'stage', 'stage_at', 'stop_reason', 'stop_at', 'phase']));
|
|
135
|
+
const isPullRequest = (v) => v === null || (isObject(v) && isString(v.url) && nullableStrings(v, ['state', 'ci', 'fetched_at']));
|
|
136
|
+
const isWorktree = (v) => v === null || (isObject(v) && typeof v.uncommitted === 'number');
|
|
137
|
+
const isAction = (v) => isObject(v) && isString(v.id) && isString(v.command) && typeof v.offered === 'boolean' && isNullableString(v.reason);
|
|
138
|
+
const isTaskRow = (v) => isObject(v) &&
|
|
139
|
+
isString(v.id) &&
|
|
140
|
+
isString(v.status) &&
|
|
141
|
+
STATUSES.has(v.status) &&
|
|
142
|
+
nullableStrings(v, ['class', 'issue', 'goal', 'created_at', 'updated_at', 'branch']) &&
|
|
143
|
+
typeof v.knowledge_consolidated === 'boolean' &&
|
|
144
|
+
typeof v.paused === 'boolean' &&
|
|
145
|
+
isString(v.base_branch) &&
|
|
146
|
+
isWorktree(v.worktree) &&
|
|
147
|
+
isGate(v.gate) &&
|
|
148
|
+
isAutopilot(v.autopilot) &&
|
|
149
|
+
isPullRequest(v.pull_request) &&
|
|
150
|
+
Array.isArray(v.housekeeping_flags) &&
|
|
151
|
+
v.housekeeping_flags.every(isString) &&
|
|
152
|
+
Array.isArray(v.actions) &&
|
|
153
|
+
v.actions.every(isAction);
|
|
154
|
+
function summary(t) {
|
|
155
|
+
const { gate, autopilot: run, pull_request: pr } = t;
|
|
156
|
+
return {
|
|
157
|
+
id: t.id,
|
|
158
|
+
class: t.class,
|
|
159
|
+
status: t.status,
|
|
160
|
+
issue: t.issue,
|
|
161
|
+
goal: t.goal,
|
|
162
|
+
knowledgeConsolidated: t.knowledge_consolidated,
|
|
163
|
+
paused: t.paused,
|
|
164
|
+
createdAt: t.created_at,
|
|
165
|
+
updatedAt: t.updated_at,
|
|
166
|
+
branch: t.branch,
|
|
167
|
+
baseBranch: t.base_branch,
|
|
168
|
+
worktree: t.worktree && { uncommitted: t.worktree.uncommitted },
|
|
169
|
+
gate: gate && { state: gate.state, by: gate.by },
|
|
170
|
+
autopilot: run && {
|
|
171
|
+
state: run.state,
|
|
172
|
+
mode: run.mode,
|
|
173
|
+
stage: run.stage,
|
|
174
|
+
stageAt: run.stage_at,
|
|
175
|
+
stopReason: run.stop_reason,
|
|
176
|
+
stopAt: run.stop_at,
|
|
177
|
+
phase: run.phase,
|
|
178
|
+
},
|
|
179
|
+
pullRequest: pr && { url: pr.url, state: pr.state, ci: pr.ci, fetchedAt: pr.fetched_at },
|
|
180
|
+
housekeepingFlags: [...t.housekeeping_flags],
|
|
181
|
+
actions: t.actions.filter((a) => ACTIONS.has(a.id)).map(({ id, command, offered, reason }) => ({ id, command, offered, reason })),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** The project's live tasks (`jig task list --json`) and how many finished ones it left out. */
|
|
122
185
|
export async function readTasks(root, platform = process.platform) {
|
|
123
186
|
const { revision, data } = await readJson(root, ['task', 'list'], platform);
|
|
124
|
-
if (!Array.isArray(data.tasks) || !data.tasks.every(
|
|
187
|
+
if (!Array.isArray(data.tasks) || !data.tasks.every(isTaskRow) || typeof data.hidden !== 'number')
|
|
188
|
+
throw new JigShapeError();
|
|
189
|
+
return { revision, tasks: data.tasks.map(summary), hidden: data.hidden };
|
|
190
|
+
}
|
|
191
|
+
const isCount = (v) => Number.isInteger(v) && v >= 0;
|
|
192
|
+
const isProgress = (v) => isObject(v) && isCount(v.done) && isCount(v.total) && isCount(v.filed) && isCount(v.fog);
|
|
193
|
+
const isItem = (v) => isObject(v) && isString(v.text) && typeof v.checked === 'boolean' && isNullableString(v.task) && typeof v.fog === 'boolean';
|
|
194
|
+
const isPhase = (v) => isObject(v) &&
|
|
195
|
+
isProgress(v) &&
|
|
196
|
+
(v.phase === null || isCount(v.phase)) &&
|
|
197
|
+
isNullableString(v.title) &&
|
|
198
|
+
Array.isArray(v.items) &&
|
|
199
|
+
v.items.every(isItem);
|
|
200
|
+
const isEpic = (v) => v === null ||
|
|
201
|
+
(isObject(v) &&
|
|
202
|
+
isString(v.branch) &&
|
|
203
|
+
(v.state === 'open' || v.state === 'finished') &&
|
|
204
|
+
typeof v.checked_out === 'boolean' &&
|
|
205
|
+
typeof v.missing === 'boolean' &&
|
|
206
|
+
isNullableString(v.ref));
|
|
207
|
+
const isSpecRow = (v) => isObject(v) &&
|
|
208
|
+
isString(v.id) &&
|
|
209
|
+
isNullableString(v.title) &&
|
|
210
|
+
Array.isArray(v.missing_files) &&
|
|
211
|
+
v.missing_files.every(isString) &&
|
|
212
|
+
isNullableString(v.release) &&
|
|
213
|
+
isEpic(v.epic) &&
|
|
214
|
+
(v.progress === null || isProgress(v.progress)) &&
|
|
215
|
+
Array.isArray(v.phases) &&
|
|
216
|
+
v.phases.every(isPhase);
|
|
217
|
+
const progress = ({ done, total, filed, fog }) => ({ done, total, filed, fog });
|
|
218
|
+
/** The project's specs (`jig spec list --json`), in Jig's order. */
|
|
219
|
+
export async function readSpecs(root, platform = process.platform) {
|
|
220
|
+
const { data } = await readJson(root, ['spec', 'list'], platform);
|
|
221
|
+
if (!Array.isArray(data.specs) || !data.specs.every(isSpecRow))
|
|
125
222
|
throw new JigShapeError();
|
|
126
|
-
return
|
|
223
|
+
return data.specs.map((s) => ({
|
|
224
|
+
id: s.id,
|
|
225
|
+
title: s.title,
|
|
226
|
+
missingFiles: [...s.missing_files],
|
|
227
|
+
release: s.release,
|
|
228
|
+
epic: s.epic && {
|
|
229
|
+
branch: s.epic.branch,
|
|
230
|
+
state: s.epic.state,
|
|
231
|
+
checkedOut: s.epic.checked_out,
|
|
232
|
+
missing: s.epic.missing,
|
|
233
|
+
fetched: s.epic.ref?.startsWith('refs/remotes/') === true,
|
|
234
|
+
},
|
|
235
|
+
progress: s.progress && progress(s.progress),
|
|
236
|
+
phases: s.phases.map((p) => ({
|
|
237
|
+
...progress(p),
|
|
238
|
+
phase: p.phase,
|
|
239
|
+
title: p.title,
|
|
240
|
+
items: p.items.map(({ text, checked, task, fog }) => ({ text, checked, task, fog })),
|
|
241
|
+
})),
|
|
242
|
+
}));
|
|
127
243
|
}
|
|
128
244
|
/** True when `version` is at least the minimum Jig UI reads. */
|
|
129
245
|
export function supports(version, minimum = MIN_JIG_VERSION) {
|
package/dist/main.js
CHANGED
|
@@ -6,7 +6,8 @@ import { DEFAULT_PORT, parseCliArgs } from './args.js';
|
|
|
6
6
|
import { createApp, readPackageVersion } from './http/app.js';
|
|
7
7
|
import { openBrowser } from './open-browser.js';
|
|
8
8
|
import { configDir } from './registry/config-dir.js';
|
|
9
|
-
import { probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
|
|
9
|
+
import { connectThrough, probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
|
|
10
|
+
import { mainCheckout } from './source/checkout.js';
|
|
10
11
|
import { localSource } from './source/local.js';
|
|
11
12
|
const HOST = '127.0.0.1';
|
|
12
13
|
const HELP = `Usage: jig-ui [options]
|
|
@@ -21,12 +22,40 @@ function fail(message) {
|
|
|
21
22
|
console.error(message);
|
|
22
23
|
process.exit(1);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// The token swap redirects to `/` with no fragment, so the browser keeps this one: the board opens.
|
|
26
|
+
const link = ({ port, token }, project) => `http://${HOST}:${port}/?token=${token}${project ? `#/projects/${project}` : ''}`;
|
|
27
|
+
async function show(info, open, dir, project) {
|
|
28
|
+
console.log(`Open it at ${link(info, project)}`);
|
|
29
|
+
if (open && !(await openBrowser(link(info, project), dir)))
|
|
28
30
|
console.log('Could not open a browser; open the link above.');
|
|
29
31
|
}
|
|
32
|
+
/** The folder this command runs in, as a checkout to connect: a worktree stands for its clone's main checkout. */
|
|
33
|
+
async function here() {
|
|
34
|
+
const checkout = await mainCheckout(process.cwd());
|
|
35
|
+
if (checkout.kind === 'main')
|
|
36
|
+
return { path: checkout.root, worktree: false };
|
|
37
|
+
// A bare clone's worktree has no main checkout: nothing to connect, said as outside a project.
|
|
38
|
+
if (checkout.kind === 'worktree' && checkout.main)
|
|
39
|
+
return { path: checkout.main, worktree: true };
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Connects the folder through the instance and returns the project's id. Outside a Jig project it says
|
|
44
|
+
* nothing: the command then only starts, as it always did.
|
|
45
|
+
*/
|
|
46
|
+
async function connectHere(info, place) {
|
|
47
|
+
if (!place)
|
|
48
|
+
return undefined;
|
|
49
|
+
const result = await connectThrough(info, place.path);
|
|
50
|
+
if ('id' in result) {
|
|
51
|
+
const what = place.worktree ? `the main checkout of this worktree, ${place.path}` : place.path;
|
|
52
|
+
console.log(result.created ? `Connected ${result.id}: ${what}.` : `${result.id} is connected: ${what}.`);
|
|
53
|
+
return result.id;
|
|
54
|
+
}
|
|
55
|
+
if (result.code !== 'not-a-repository' && result.code !== 'no-jig')
|
|
56
|
+
console.log(`Could not connect this folder: ${result.message}`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
30
59
|
/** The instance recorded in the config directory, if it still answers with its token. */
|
|
31
60
|
async function runningInstance(dir) {
|
|
32
61
|
const info = await readInstance(dir);
|
|
@@ -56,13 +85,14 @@ export async function main() {
|
|
|
56
85
|
}
|
|
57
86
|
const { port, open } = command;
|
|
58
87
|
const dir = configDir(process.env, process.platform, homedir());
|
|
88
|
+
const place = await here();
|
|
59
89
|
const running = await runningInstance(dir);
|
|
60
90
|
if (running) {
|
|
61
91
|
console.log(`Jig UI is already running on port ${running.port}.`);
|
|
62
92
|
// ponytail: a --port equal to the default is indistinguishable from no flag; no note then.
|
|
63
93
|
if (port !== running.port && port !== DEFAULT_PORT)
|
|
64
94
|
console.log(`--port ${port} is ignored.`);
|
|
65
|
-
await show(running, open, dir);
|
|
95
|
+
await show(running, open, dir, await connectHere(running, place));
|
|
66
96
|
return;
|
|
67
97
|
}
|
|
68
98
|
const version = readPackageVersion();
|
|
@@ -71,7 +101,7 @@ export async function main() {
|
|
|
71
101
|
void (async () => {
|
|
72
102
|
await writeInstance(dir, { port, token, pid: process.pid, version });
|
|
73
103
|
console.log(`Jig UI ${version} is running at http://${HOST}:${port}/`);
|
|
74
|
-
await show({ port, token }, open, dir);
|
|
104
|
+
await show({ port, token }, open, dir, await connectHere({ port, token }, place));
|
|
75
105
|
console.log('Press Ctrl+C to stop.');
|
|
76
106
|
})().catch((error) => fail(`Could not start: ${error instanceof Error ? error.message : String(error)}`));
|
|
77
107
|
});
|
|
@@ -83,7 +113,7 @@ export async function main() {
|
|
|
83
113
|
if (!winner)
|
|
84
114
|
fail(`Port ${port} is already in use. Stop the other process or choose another port with --port.`);
|
|
85
115
|
console.log(`Jig UI is already running on port ${winner.port}.`);
|
|
86
|
-
await show(winner, open, dir);
|
|
116
|
+
await show(winner, open, dir, await connectHere(winner, place));
|
|
87
117
|
process.exit(0);
|
|
88
118
|
});
|
|
89
119
|
});
|
package/dist/node-version.js
CHANGED
|
@@ -8,7 +8,6 @@ export function checkNodeVersion(version) {
|
|
|
8
8
|
}
|
|
9
9
|
return {
|
|
10
10
|
ok: false,
|
|
11
|
-
message: 'Jig UI needs Node.js ' + MINIMUM_NODE_MAJOR + ' or later, but this is ' + version +
|
|
12
|
-
'. Install a newer Node.js and run it again.',
|
|
11
|
+
message: 'Jig UI needs Node.js ' + MINIMUM_NODE_MAJOR + ' or later, but this is ' + version + '. Install a newer Node.js and run it again.',
|
|
13
12
|
};
|
|
14
13
|
}
|
|
@@ -48,3 +48,24 @@ export async function probeInstance(info, timeoutMs = 1000) {
|
|
|
48
48
|
return false;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/** Asks the instance to connect `path`, as the page does: the instance owns the registry, so no second process writes it. */
|
|
52
|
+
export async function connectThrough(info, path, timeoutMs = 15_000) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/projects`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { cookie: `${cookieName(info.port)}=${info.token}`, 'content-type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ path }),
|
|
58
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
59
|
+
});
|
|
60
|
+
// A failure the instance did not foresee answers in plain text.
|
|
61
|
+
const body = (await res.json().catch(() => ({})));
|
|
62
|
+
if (body.project)
|
|
63
|
+
return { id: body.project.id, created: res.status === 201 };
|
|
64
|
+
if (typeof body.error === 'object')
|
|
65
|
+
return body.error;
|
|
66
|
+
return { code: 'unexpected', message: `Jig UI answered ${res.status}.` };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { code: 'unreachable', message: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/source/checkout.js
CHANGED
|
@@ -21,5 +21,23 @@ export async function mainCheckout(path) {
|
|
|
21
21
|
const [top, own, common] = stdout.trim().split(/\r?\n/);
|
|
22
22
|
if (!top || !own || !common)
|
|
23
23
|
return { kind: 'not-a-repository' };
|
|
24
|
-
|
|
24
|
+
if ((await realpath(own)) === (await realpath(common)))
|
|
25
|
+
return { kind: 'main', root: await realpath(top) };
|
|
26
|
+
return { kind: 'worktree', main: await mainWorktree(path) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The main working tree, which `git worktree list` names first — not `dirname(--git-common-dir)`, which
|
|
30
|
+
* misses for a worktree of a submodule. A bare clone's first entry is the repository itself.
|
|
31
|
+
*/
|
|
32
|
+
async function mainWorktree(path) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await run('git', ['worktree', 'list', '--porcelain'], { cwd: path, timeout: 10_000 });
|
|
35
|
+
const [first, second] = stdout.split(/\r?\n/);
|
|
36
|
+
if (!first?.startsWith('worktree ') || second === 'bare')
|
|
37
|
+
return null;
|
|
38
|
+
return await realpath(first.slice('worktree '.length));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
25
43
|
}
|
package/dist/source/local.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { JigRefusal, JigShapeError, jigVersion, MIN_JIG_VERSION, readStatus, readTasks, supports } from '../jig/client.js';
|
|
2
|
+
import { JigRefusal, JigShapeError, jigVersion, MIN_JIG_VERSION, readSpecs, readStatus, readTasks, supports, watchDir, writtenByReads, } from '../jig/client.js';
|
|
3
3
|
import { addProject, readProjects, removeProject } from '../registry/projects.js';
|
|
4
|
+
import { watchTree } from '../watch/watch.js';
|
|
4
5
|
import { mainCheckout } from './checkout.js';
|
|
5
6
|
/** Jig's version in the checkout: `null` when Jig is there but names no version Jig UI can read. */
|
|
6
7
|
async function version(root) {
|
|
@@ -32,8 +33,46 @@ function count(tasks) {
|
|
|
32
33
|
ready: tasks.filter((t) => t.status === 'ready' && !t.paused).length,
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
36
|
+
/** A live task as a board's card shows it. */
|
|
37
|
+
const card = (t) => ({
|
|
38
|
+
id: t.id,
|
|
39
|
+
class: t.class,
|
|
40
|
+
status: t.status,
|
|
41
|
+
goal: t.goal,
|
|
42
|
+
paused: t.paused,
|
|
43
|
+
started: t.branch !== null,
|
|
44
|
+
gate: t.gate?.state ?? null,
|
|
45
|
+
autopilot: t.autopilot?.state ?? null,
|
|
46
|
+
pullRequest: t.pullRequest,
|
|
47
|
+
});
|
|
48
|
+
/** A live task in full; where its worktree is was never read, and an action Jig refuses is left out. */
|
|
49
|
+
const details = (t, attention) => ({
|
|
50
|
+
...card(t),
|
|
51
|
+
issue: t.issue,
|
|
52
|
+
branch: t.branch,
|
|
53
|
+
baseBranch: t.baseBranch,
|
|
54
|
+
createdAt: t.createdAt,
|
|
55
|
+
updatedAt: t.updatedAt,
|
|
56
|
+
knowledgeConsolidated: t.knowledgeConsolidated,
|
|
57
|
+
worktree: t.worktree,
|
|
58
|
+
gateBy: t.gate?.by ?? null,
|
|
59
|
+
run: t.autopilot && {
|
|
60
|
+
mode: t.autopilot.mode,
|
|
61
|
+
stage: t.autopilot.stage,
|
|
62
|
+
stageAt: t.autopilot.stageAt,
|
|
63
|
+
stopReason: t.autopilot.stopReason,
|
|
64
|
+
stopAt: t.autopilot.stopAt,
|
|
65
|
+
phase: t.autopilot.phase,
|
|
66
|
+
},
|
|
67
|
+
housekeepingFlags: t.housekeepingFlags,
|
|
68
|
+
actions: t.actions.filter((a) => a.offered).map(({ id, command }) => ({ id, command })),
|
|
69
|
+
attention: attention.filter((a) => a.tasks.includes(t.id)),
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* Runs `read` on a connected project whose Jig Jig UI reads; a project gone, below the minimum, or
|
|
73
|
+
* refusing the read comes back as its state instead.
|
|
74
|
+
*/
|
|
75
|
+
async function reading({ id, path }, read) {
|
|
37
76
|
if (!existsSync(path))
|
|
38
77
|
return { id, state: 'unavailable', reason: 'missing' };
|
|
39
78
|
const v = await version(path);
|
|
@@ -42,15 +81,7 @@ async function view({ id, path }) {
|
|
|
42
81
|
if (v === null || !supports(v))
|
|
43
82
|
return { id, state: 'unsupported', jigVersion: v, minimum: MIN_JIG_VERSION };
|
|
44
83
|
try {
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
id,
|
|
48
|
-
state: 'ready',
|
|
49
|
-
jigVersion: v,
|
|
50
|
-
counts: count(tasks),
|
|
51
|
-
attention: status.attention,
|
|
52
|
-
housekeepingStale: status.housekeepingStale,
|
|
53
|
-
};
|
|
84
|
+
return await read(path, v);
|
|
54
85
|
}
|
|
55
86
|
catch (error) {
|
|
56
87
|
if (error instanceof JigShapeError)
|
|
@@ -59,21 +90,188 @@ async function view({ id, path }) {
|
|
|
59
90
|
return { id, state: 'unreadable', code: error instanceof JigRefusal ? error.code : 'failed' };
|
|
60
91
|
}
|
|
61
92
|
}
|
|
62
|
-
|
|
63
|
-
|
|
93
|
+
async function readProject(project) {
|
|
94
|
+
const read = await reading(project, async (root, jigVersion) => {
|
|
95
|
+
const [status, list, specs] = await Promise.all([readStatus(root), readTasks(root), readSpecs(root)]);
|
|
96
|
+
return { status, list, specs, jigVersion };
|
|
97
|
+
});
|
|
98
|
+
if (!('list' in read))
|
|
99
|
+
return { view: read, tasks: read, details: [], specs: read, revision: null };
|
|
100
|
+
const { status, list, specs, jigVersion } = read;
|
|
101
|
+
// Jig's live list holds active and ready tasks only.
|
|
102
|
+
const live = list.tasks.filter((t) => t.status === 'active' || t.status === 'ready');
|
|
103
|
+
return {
|
|
104
|
+
view: {
|
|
105
|
+
id: project.id,
|
|
106
|
+
state: 'ready',
|
|
107
|
+
jigVersion,
|
|
108
|
+
counts: count(list.tasks),
|
|
109
|
+
attention: status.attention,
|
|
110
|
+
housekeepingStale: status.housekeepingStale,
|
|
111
|
+
},
|
|
112
|
+
tasks: { state: 'ready', tasks: live.map(card), finished: list.hidden },
|
|
113
|
+
details: live.map((t) => details(t, status.attention)),
|
|
114
|
+
specs: { state: 'ready', specs },
|
|
115
|
+
revision: list.revision,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** A read that may have caught Jig mid-write: worth one more try before it is believed. */
|
|
119
|
+
const torn = (read) => read.view.state === 'unreadable' || read.view.state === 'incompatible';
|
|
120
|
+
/**
|
|
121
|
+
* The local adapter: the registry in the config directory, Jig run in each main checkout, and a
|
|
122
|
+
* Watcher on each project's `.ai/` while someone listens. Then reads are served from what the
|
|
123
|
+
* watch last read, and a change is signalled only when a new read differs from it.
|
|
124
|
+
*/
|
|
125
|
+
export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs } = {}) {
|
|
126
|
+
const entries = new Map();
|
|
127
|
+
const listeners = new Set();
|
|
128
|
+
let rescan;
|
|
129
|
+
/** Counts the times following started; a read that finishes after a stop belongs to none. */
|
|
130
|
+
let session = 0;
|
|
131
|
+
const following = () => rescan !== undefined;
|
|
132
|
+
const entryOf = (project) => {
|
|
133
|
+
let entry = entries.get(project.id);
|
|
134
|
+
if (!entry)
|
|
135
|
+
entries.set(project.id, (entry = { project, held: false, dirty: false }));
|
|
136
|
+
return entry;
|
|
137
|
+
};
|
|
138
|
+
/** Keeps what was shown over one torn read; otherwise the read is what the project shows. */
|
|
139
|
+
function settle(entry, read) {
|
|
140
|
+
if (torn(read) && entry.shown && !torn(entry.shown) && !entry.held) {
|
|
141
|
+
entry.held = true;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
entry.held = false;
|
|
145
|
+
entry.shown = read;
|
|
146
|
+
entry.readIn = following() ? session : undefined;
|
|
147
|
+
}
|
|
148
|
+
/** Reads the project again — one read at a time — and signals when what it shows changed. */
|
|
149
|
+
function refresh(entry) {
|
|
150
|
+
if (entry.reading) {
|
|
151
|
+
entry.dirty = true;
|
|
152
|
+
return entry.reading;
|
|
153
|
+
}
|
|
154
|
+
entry.reading = (async () => {
|
|
155
|
+
do {
|
|
156
|
+
entry.dirty = false;
|
|
157
|
+
const before = JSON.stringify(entry.shown);
|
|
158
|
+
settle(entry, await readProject(entry.project));
|
|
159
|
+
if (following() && entries.get(entry.project.id) === entry && JSON.stringify(entry.shown) !== before) {
|
|
160
|
+
signal({ id: entry.project.id, revision: entry.shown?.revision ?? null });
|
|
161
|
+
}
|
|
162
|
+
} while (entry.dirty);
|
|
163
|
+
})().finally(() => (entry.reading = undefined));
|
|
164
|
+
return entry.reading;
|
|
165
|
+
}
|
|
166
|
+
function signal(change) {
|
|
167
|
+
for (const listener of listeners)
|
|
168
|
+
listener(change);
|
|
169
|
+
}
|
|
170
|
+
/** What the project shows: from the watch while following, read through Jig otherwise. */
|
|
171
|
+
async function shown(project) {
|
|
172
|
+
const entry = entryOf(project);
|
|
173
|
+
if (!(following() && entry.shown && entry.readIn === session))
|
|
174
|
+
await (entry.reading ?? refresh(entry));
|
|
175
|
+
return entry.shown;
|
|
176
|
+
}
|
|
177
|
+
function follow(entry) {
|
|
178
|
+
if (entry.watch?.open)
|
|
179
|
+
return;
|
|
180
|
+
entry.watch = watchTree(watchDir(entry.project.path), {
|
|
181
|
+
ignore: writtenByReads,
|
|
182
|
+
onChange: () => void refresh(entry),
|
|
183
|
+
quietMs,
|
|
184
|
+
maxWaitMs,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Follows every registered project and reads it again; forgets one no longer registered, such as
|
|
189
|
+
* one a read in flight brought back after its disconnect.
|
|
190
|
+
*/
|
|
191
|
+
async function sweep() {
|
|
192
|
+
// An unreadable registry is the page's to show, through its own read.
|
|
193
|
+
const projects = await readProjects(configDir).catch(() => undefined);
|
|
194
|
+
if (!projects || !following())
|
|
195
|
+
return;
|
|
196
|
+
const ids = new Set(projects.map((p) => p.id));
|
|
197
|
+
for (const [id, entry] of entries) {
|
|
198
|
+
if (ids.has(id))
|
|
199
|
+
continue;
|
|
200
|
+
entry.watch?.close();
|
|
201
|
+
entries.delete(id);
|
|
202
|
+
}
|
|
203
|
+
for (const project of projects) {
|
|
204
|
+
const entry = entryOf(project);
|
|
205
|
+
follow(entry); // a watcher that failed, or a directory that came back
|
|
206
|
+
void refresh(entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function start() {
|
|
210
|
+
session++;
|
|
211
|
+
rescan = setInterval(() => void sweep(), rescanMs);
|
|
212
|
+
rescan.unref();
|
|
213
|
+
void sweep();
|
|
214
|
+
}
|
|
215
|
+
/** Stops following; what was read while following may be old by the next subscribe, so it goes. */
|
|
216
|
+
function stop() {
|
|
217
|
+
clearInterval(rescan);
|
|
218
|
+
rescan = undefined;
|
|
219
|
+
for (const entry of entries.values()) {
|
|
220
|
+
entry.watch?.close();
|
|
221
|
+
entry.watch = undefined;
|
|
222
|
+
entry.shown = undefined;
|
|
223
|
+
entry.held = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
64
226
|
return {
|
|
65
227
|
async workspace() {
|
|
66
|
-
return Promise.all((await readProjects(configDir)).map(view));
|
|
228
|
+
return Promise.all((await readProjects(configDir)).map(async (p) => (await shown(p)).view));
|
|
67
229
|
},
|
|
68
230
|
async connect(path) {
|
|
69
231
|
const found = await connectable(path);
|
|
70
232
|
if ('code' in found)
|
|
71
233
|
return found;
|
|
72
234
|
const { project, created } = await addProject(configDir, found.root);
|
|
73
|
-
|
|
235
|
+
const entry = entryOf(project);
|
|
236
|
+
if (following())
|
|
237
|
+
follow(entry);
|
|
238
|
+
await refresh(entry);
|
|
239
|
+
return { project: entry.shown.view, created };
|
|
240
|
+
},
|
|
241
|
+
async tasks(id) {
|
|
242
|
+
const project = (await readProjects(configDir)).find((p) => p.id === id);
|
|
243
|
+
return project && (await shown(project)).tasks;
|
|
244
|
+
},
|
|
245
|
+
async task(id, taskId) {
|
|
246
|
+
const project = (await readProjects(configDir)).find((p) => p.id === id);
|
|
247
|
+
if (!project)
|
|
248
|
+
return undefined;
|
|
249
|
+
const read = await shown(project);
|
|
250
|
+
if (read.tasks.state !== 'ready')
|
|
251
|
+
return read.tasks;
|
|
252
|
+
return { state: 'ready', task: read.details.find((t) => t.id === taskId) ?? null };
|
|
253
|
+
},
|
|
254
|
+
async specs(id) {
|
|
255
|
+
const project = (await readProjects(configDir)).find((p) => p.id === id);
|
|
256
|
+
return project && (await shown(project)).specs;
|
|
257
|
+
},
|
|
258
|
+
async disconnect(id) {
|
|
259
|
+
if (!(await removeProject(configDir, id)))
|
|
260
|
+
return false;
|
|
261
|
+
entries.get(id)?.watch?.close();
|
|
262
|
+
entries.delete(id);
|
|
263
|
+
if (following())
|
|
264
|
+
signal({ id, revision: null });
|
|
265
|
+
return true;
|
|
74
266
|
},
|
|
75
|
-
|
|
76
|
-
|
|
267
|
+
subscribe(listener) {
|
|
268
|
+
listeners.add(listener);
|
|
269
|
+
if (!following())
|
|
270
|
+
start();
|
|
271
|
+
return () => {
|
|
272
|
+
if (listeners.delete(listener) && listeners.size === 0)
|
|
273
|
+
stop();
|
|
274
|
+
};
|
|
77
275
|
},
|
|
78
276
|
};
|
|
79
277
|
}
|