@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.
@@ -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 }));
@@ -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
- const isTaskSummary = (v) => isObject(v) && isString(v.id) && isString(v.status) && STATUSES.has(v.status) && typeof v.paused === 'boolean';
121
- /** The project's live tasks (`jig task list --json`, finished ones left out). */
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(isTaskSummary))
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 { revision, tasks: data.tasks.map(({ id, status, paused }) => ({ id, status, paused })) };
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
- const link = ({ port, token }) => `http://${HOST}:${port}/?token=${token}`;
25
- async function show(info, open, dir) {
26
- console.log(`Open it at ${link(info)}`);
27
- if (open && !(await openBrowser(link(info), dir)))
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
  });
@@ -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
+ }
@@ -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
- return (await realpath(own)) === (await realpath(common)) ? { kind: 'main', root: await realpath(top) } : { kind: 'worktree' };
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
  }
@@ -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
- /** Reads a connected project; one below the minimum Jig, or gone, is not read any further. */
36
- async function view({ id, path }) {
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
- const [status, { tasks }] = await Promise.all([readStatus(path), readTasks(path)]);
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
- /** The local adapter: the registry in the config directory, and Jig run in each main checkout. */
63
- export function localSource(configDir) {
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
- return { project: await view(project), created };
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
- disconnect(id) {
76
- return removeProject(configDir, id);
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
  }