@byokit/herdr 0.1.0-next.dbe7142 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,19 @@
1
1
  import type { HerdrKit } from '../kit.ts';
2
2
  import type { FakeHerdr } from './fake-herdr/server.ts';
3
- import type { HerdrTransport } from '../types.ts';
3
+ import type { HerdrProtocolRange, HerdrTransport } from '../types.ts';
4
4
  export type HerdrContractBench = {
5
5
  kit: HerdrKit;
6
6
  fake?: FakeHerdr;
7
- withTransport?: (transport: HerdrTransport) => HerdrKit;
7
+ withTransport?: (transport: HerdrTransport, o?: {
8
+ protocolRange?: HerdrProtocolRange;
9
+ }) => HerdrKit;
8
10
  };
9
- export declare function herdrContract(make: () => Promise<HerdrContractBench>): void;
11
+ export type HerdrContractTestContext = {
12
+ skip: (message?: string) => void;
13
+ };
14
+ export type HerdrContractTestFn = (name: string, fn: (t: HerdrContractTestContext) => void | Promise<void>) => void | Promise<void>;
15
+ export type HerdrContractOptions = {
16
+ /** The runner's `test` (node:test's by default; pass vitest's `test` under vitest). */
17
+ test?: HerdrContractTestFn;
18
+ };
19
+ export declare function herdrContract(make: () => Promise<HerdrContractBench>, options?: HerdrContractOptions | HerdrContractTestFn): void;
@@ -2,10 +2,12 @@
2
2
  // in later work packages' tests (H3/H5 wire `make` up) and against the real pinned Herdr in the lab
3
3
  // (H9, which passes no `fake`). Cases that need to script the transport's wire answers use
4
4
  // `withTransport`; when a `make` omits it, those cases skip.
5
- import { test } from 'node:test';
5
+ import { test as nodeTest } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import { HERDR_PROTOCOL } from "../constants.js";
8
- export function herdrContract(make) {
8
+ export function herdrContract(make, options) {
9
+ const runTest = typeof options === 'function' ? options : (options?.test ?? nodeTest);
10
+ const test = runTest;
9
11
  const settle = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
12
  async function until(probe, ok, ms = 2000) {
11
13
  const deadline = Date.now() + ms;
@@ -29,6 +31,14 @@ export function herdrContract(make) {
29
31
  await made.fake?.stop().catch(() => { });
30
32
  }
31
33
  }
34
+ // Transport doubles that never exercise the control surface still satisfy its type.
35
+ function ackedStop() {
36
+ return Object.assign(() => { }, {
37
+ ready: Promise.resolve(true),
38
+ onReconnect() { },
39
+ onDisconnect() { },
40
+ });
41
+ }
32
42
  function agentStatusOf(snapshot, paneId) {
33
43
  let panes = 0;
34
44
  let status;
@@ -52,24 +62,96 @@ export function herdrContract(make) {
52
62
  assert.equal(typeof ping.version, 'string');
53
63
  });
54
64
  });
55
- test('contract: a protocol mismatch reports needs-update', async (t) => {
65
+ test('contract: a newer server is needs-update state, not a throw — the kit stays usable', async (t) => {
56
66
  await bench(async ({ withTransport }) => {
57
67
  if (!withTransport)
58
68
  return t.skip('make provides no transport double');
69
+ const snapshot = {
70
+ protocol: HERDR_PROTOCOL + 999,
71
+ snapshot: {
72
+ protocol: HERDR_PROTOCOL + 999,
73
+ workspaces: [{ workspace_id: 'w1', label: 'x' }],
74
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
75
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }],
76
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 1 }],
77
+ },
78
+ };
59
79
  const kit = withTransport({
60
- call: async () => ({ protocol: HERDR_PROTOCOL + 999 }),
61
- subscribe: () => () => { },
80
+ call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 999 }),
81
+ subscribe: () => ackedStop(),
62
82
  close: () => { },
63
83
  });
64
84
  try {
65
- await kit.start().catch(() => { });
85
+ await kit.start();
66
86
  assert.equal(kit.state.phase, 'needs-update');
87
+ assert.equal(kit.state.why, 'version');
88
+ assert.equal(kit.snapshot().workspaces.length, 1, 'the snapshot still installs');
89
+ assert.ok(kit.snapshot().connected, 'the kit stays connected');
90
+ await kit.call('ping', {});
67
91
  }
68
92
  finally {
69
93
  await kit.stop().catch(() => { });
70
94
  }
71
95
  });
72
96
  });
97
+ test('contract: an older server fails closed', async (t) => {
98
+ await bench(async ({ withTransport }) => {
99
+ if (!withTransport)
100
+ return t.skip('make provides no transport double');
101
+ const kit = withTransport({
102
+ call: async () => ({ protocol: HERDR_PROTOCOL - 1 }),
103
+ subscribe: () => ackedStop(),
104
+ close: () => { },
105
+ });
106
+ try {
107
+ await assert.rejects(kit.start(), /protocol mismatch/);
108
+ assert.equal(kit.state.phase, 'needs-update');
109
+ }
110
+ finally {
111
+ await kit.stop().catch(() => { });
112
+ }
113
+ });
114
+ });
115
+ test('contract: a declared protocol range accepts a newer server as ready', async (t) => {
116
+ await bench(async ({ withTransport }) => {
117
+ if (!withTransport)
118
+ return t.skip('make provides no transport double');
119
+ const snapshot = {
120
+ protocol: HERDR_PROTOCOL + 1,
121
+ snapshot: {
122
+ protocol: HERDR_PROTOCOL + 1,
123
+ workspaces: [{ workspace_id: 'w1', label: 'x' }],
124
+ tabs: [{ tab_id: 'w1:t1', workspace_id: 'w1', label: 'main' }],
125
+ panes: [{ pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }],
126
+ agents: [{ pane_id: 'w1:p2', agent_status: 'idle', revision: 1 }],
127
+ },
128
+ };
129
+ const transport = {
130
+ call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL + 1 }),
131
+ subscribe: () => ackedStop(),
132
+ close: () => { },
133
+ };
134
+ const inRange = withTransport(transport);
135
+ try {
136
+ await inRange.start();
137
+ assert.equal(inRange.state.phase, 'needs-update', 'without the range a newer server is needs-update');
138
+ }
139
+ finally {
140
+ await inRange.stop().catch(() => { });
141
+ }
142
+ const ranged = withTransport(transport, {
143
+ protocolRange: { min: HERDR_PROTOCOL, max: HERDR_PROTOCOL + 1 },
144
+ });
145
+ try {
146
+ await ranged.start();
147
+ assert.equal(ranged.state.phase, 'ready', 'the declared range accepts the newer server');
148
+ assert.equal(ranged.snapshot().workspaces.length, 1);
149
+ }
150
+ finally {
151
+ await ranged.stop().catch(() => { });
152
+ }
153
+ });
154
+ });
73
155
  test('contract: an event racing the bootstrap snapshot is applied after it, once', async (t) => {
74
156
  await bench(async ({ withTransport }) => {
75
157
  if (!withTransport)
@@ -83,12 +165,12 @@ export function herdrContract(make) {
83
165
  },
84
166
  };
85
167
  const kit = withTransport({
86
- // The ack carries the race: `pane.updated` lands after the subscribe but before the kit
168
+ // The ack carries the race: `pane_updated` lands after the subscribe but before the kit
87
169
  // installs the snapshot, so buffering must order it after — and never duplicate it.
88
170
  call: async (method) => (method === 'session.snapshot' ? snapshot : { protocol: HERDR_PROTOCOL }),
89
171
  subscribe: (_subs, on) => {
90
- queueMicrotask(() => on({ type: 'pane.updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
91
- return () => { };
172
+ queueMicrotask(() => on({ type: 'pane_updated', pane: { pane_id: 'w1:p2', agent_status: 'working' } }));
173
+ return ackedStop();
92
174
  },
93
175
  close: () => { },
94
176
  });
@@ -116,7 +198,7 @@ export function herdrContract(make) {
116
198
  if (subs.some((s) => s.type === 'pane.agent_status_changed')) {
117
199
  onError('invalid_subscription', 'pane.agent_status_changed needs a pane_id');
118
200
  }
119
- return () => { };
201
+ return ackedStop();
120
202
  },
121
203
  close: () => { },
122
204
  });
@@ -126,9 +208,17 @@ export function herdrContract(make) {
126
208
  // Deliberately schema-invalid: the pinned schema (6.7) requires pane_id for this filtered
127
209
  // kind, so the server rejects the batch — the exact answer the transport double gives here.
128
210
  const rejected = { type: 'pane.agent_status_changed' };
129
- kit.subscribe([rejected], () => { });
211
+ const errors = [];
212
+ const stop = kit.subscribe([rejected], () => { }, (code, message) => errors.push({ code, message }));
213
+ assert.equal(typeof stop, 'function');
214
+ assert.ok(stop.ready instanceof Promise, 'the stop carries the ack promise');
215
+ assert.equal(typeof stop.onReconnect, 'function');
216
+ assert.equal(typeof stop.onDisconnect, 'function');
130
217
  await settle(1200); // longer than the 1 s reconnect delay a retry would use
218
+ assert.deepEqual(errors.map((e) => e.code), ['invalid_subscription'], 'the rejection reaches onError exactly once');
219
+ assert.match(errors[0].message, /pane_id/);
131
220
  assert.equal(subscribeCalls, bootstrapCalls + 1, 'the rejected subscription is never retried');
221
+ stop();
132
222
  }
133
223
  finally {
134
224
  await kit.stop().catch(() => { });
@@ -145,6 +235,8 @@ export function herdrContract(make) {
145
235
  await settle(100); // the subscribe registers on its own socket; an emit before that never arrives
146
236
  fake.emit({ type: 'pane.created', pane_id: 'w9:p9' });
147
237
  await until(() => seen.length, (n) => n >= 1);
238
+ // The fake emits the live underscore spelling; the kit matches both spellings.
239
+ assert.equal(seen[0].type, 'pane_created');
148
240
  assert.equal(seen[0].pane_id, 'w9:p9');
149
241
  stop();
150
242
  fake.emit({ type: 'pane.created', pane_id: 'w9:p10' });
@@ -191,6 +283,18 @@ export function herdrContract(make) {
191
283
  assert.ok(workspaces.workspaces?.some((workspace) => workspace.worktree?.is_linked_worktree === true), 'the worktree placement registers a linked checkout');
192
284
  });
193
285
  });
286
+ test('contract: a started agent joins snapshot() and its status stays current there', async () => {
287
+ await bench(async ({ kit }) => {
288
+ await kit.start();
289
+ const { paneId } = await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'new' } });
290
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'idle');
291
+ await kit.prompt({ paneId }, 'ask permission');
292
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'blocked');
293
+ const blocked = await until(() => kit.blocked().find((b) => b.paneId === paneId), () => true);
294
+ await kit.answer(paneId, ['y'], { revision: blocked.revision });
295
+ await until(() => agentStatusOf(kit.snapshot(), paneId).status, (status) => status === 'idle');
296
+ });
297
+ });
194
298
  test('contract: prompt validates the receipt and appends the reply text', async () => {
195
299
  await bench(async ({ kit }) => {
196
300
  await kit.start();
@@ -223,7 +327,7 @@ export function herdrContract(make) {
223
327
  }
224
328
  return { protocol: HERDR_PROTOCOL };
225
329
  },
226
- subscribe: () => () => { },
330
+ subscribe: () => ackedStop(),
227
331
  close: () => { },
228
332
  });
229
333
  try {
@@ -2,9 +2,9 @@
2
2
  // `api schema`, the `server` verb, JSON CLI verbs and `terminal session`. World state lives on the
3
3
  // control socket; this process never speaks JSON-RPC except to forward a CLI verb. Ported from muxr
4
4
  // `perf/fake-herdr/bin.mjs` with the perf byte rates and graphics probes removed.
5
- import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { createConnection } from 'node:net';
7
- import { dirname, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { HERDR_PROTOCOL, HERDR_VERSION } from "../../constants.js";
10
10
  const SELF = fileURLToPath(import.meta.url);
@@ -82,13 +82,20 @@ async function runServer() {
82
82
  process.on('SIGTERM', stop);
83
83
  process.on('SIGINT', stop);
84
84
  }
85
- // `{"type":"fake.stream","count":n,"size":b,"progress":file}` on stdin: n frames with data `<i>:` plus b bytes,
86
- // written as fast as the pipe takes them. After each write the frames-written count lands in `progress`, so a
87
- // test can see how far a paused reader let the stream run.
85
+ // Terminal frames carry the real Herdr shape — `{ type: 'terminal.frame', pane_id, full, bytes }`
86
+ // with `bytes` the UTF-8 length of `full` — plus the fake's `data` alias (same string as `full`),
87
+ // so older assertions reading `data` keep passing. A `{"type":"terminal.resize",cols,rows}` line
88
+ // resizes the session and answers one empty frame stamped with the new size; `terminal.release`
89
+ // exits; `{"type":"fake.stream","count":n,"size":b,"progress":file}` streams n frames with full
90
+ // `<i>:` plus b bytes as fast as the pipe takes them, recording the frames-written count in
91
+ // `progress` after each write so a test can see how far a paused reader let the stream run.
92
+ function frame(paneId, full, extra = {}) {
93
+ return `${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data: full, full, bytes: Buffer.byteLength(full), ...extra })}\n`;
94
+ }
88
95
  async function stream(paneId, o) {
89
96
  const pad = 'x'.repeat(o.size ?? 1024);
90
97
  for (let i = 0; i < (o.count ?? 0); i += 1) {
91
- const ok = process.stdout.write(`${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data: `${i}:${pad}` })}\n`);
98
+ const ok = process.stdout.write(frame(paneId, `${i}:${pad}`));
92
99
  if (o.progress)
93
100
  writeFileSync(o.progress, String(i + 1));
94
101
  if (!ok)
@@ -98,12 +105,12 @@ async function stream(paneId, o) {
98
105
  function runTerminal(args) {
99
106
  // `herdr terminal session <control|observe> <pane> [--takeover] --cols <n> --rows <n>`.
100
107
  // The fake needs no server for this: one ready frame, then every send line echoes back as an
101
- // output frame (docs/runtime-kits.md 6.8), except `fake.stream` below.
108
+ // output frame (docs/runtime-kits.md 6.8), except `fake.stream` and `terminal.resize` above.
102
109
  const mode = args[0] === 'observe' ? 'observe' : 'control';
103
110
  const rest = args[0] === 'control' || args[0] === 'observe' ? args.slice(1) : args;
104
111
  const paneId = rest[0] ?? 'w1:p1';
105
- const cols = Number(flag(args, '--cols')) || 80;
106
- const rows = Number(flag(args, '--rows')) || 24;
112
+ let cols = Number(flag(args, '--cols')) || 80;
113
+ let rows = Number(flag(args, '--rows')) || 24;
107
114
  const takeover = args.includes('--takeover');
108
115
  process.stdout.write(`${JSON.stringify({ type: 'terminal.ready', pane_id: paneId, mode, takeover, cols, rows })}\n`);
109
116
  let buffer = '';
@@ -116,6 +123,7 @@ function runTerminal(args) {
116
123
  if (line.trim() === '')
117
124
  continue;
118
125
  let data = line;
126
+ let extra = {};
119
127
  try {
120
128
  const message = JSON.parse(line);
121
129
  if (message.type === 'terminal.release')
@@ -124,11 +132,20 @@ function runTerminal(args) {
124
132
  void stream(paneId, message);
125
133
  continue;
126
134
  }
135
+ if (message.type === 'terminal.resize') {
136
+ if (typeof message.cols === 'number' && message.cols > 0)
137
+ cols = Math.floor(message.cols);
138
+ if (typeof message.rows === 'number' && message.rows > 0)
139
+ rows = Math.floor(message.rows);
140
+ process.stdout.write(frame(paneId, '', { cols, rows }));
141
+ continue;
142
+ }
143
+ // `terminal.input` carries the keys in `data`; anything else with a string `data` echoes it.
127
144
  if (typeof message.data === 'string')
128
145
  data = message.data;
129
146
  }
130
147
  catch { /* a raw line echoes as-is */ }
131
- process.stdout.write(`${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data })}\n`);
148
+ process.stdout.write(frame(paneId, data, { cols, rows }));
132
149
  }
133
150
  });
134
151
  process.stdin.on('end', () => process.exit(0));
@@ -186,8 +203,20 @@ export async function main(argv) {
186
203
  return;
187
204
  }
188
205
  if (cmd === 'api' && sub === 'schema') {
189
- // Reports the pinned v0.9.1 snapshot identity (schema/herdr-api-0.9.1.json, docs/runtime-kits.md 6.7).
190
- process.stdout.write(`${JSON.stringify({ title: 'herdr api schema', protocol: HERDR_PROTOCOL, version: HERDR_VERSION })}\n`);
206
+ // Prints the pinned v0.9.1 snapshot itself (schema/herdr-api-0.9.1.json, docs/runtime-kits.md
207
+ // 6.7) — the same bytes `scripts/gen-types.ts` generates the typed surface from — exactly as
208
+ // the real `herdr api schema` prints its bundled schema. Only when the snapshot file is
209
+ // unreachable (a packed install without it) does the shim fall back to the pinned identity.
210
+ try {
211
+ const snapshot = readFileSync(join(dirname(SELF), '../../../schema/herdr-api-0.9.1.json'), 'utf8');
212
+ const parsed = JSON.parse(snapshot);
213
+ if (parsed.protocol !== HERDR_PROTOCOL)
214
+ throw new Error('fake-herdr bin: schema snapshot drift');
215
+ process.stdout.write(snapshot.endsWith('\n') ? snapshot : `${snapshot}\n`);
216
+ }
217
+ catch {
218
+ process.stdout.write(`${JSON.stringify({ protocol: HERDR_PROTOCOL, version: HERDR_VERSION })}\n`);
219
+ }
191
220
  return;
192
221
  }
193
222
  if (cmd === 'server') {
@@ -1,21 +1,37 @@
1
- import { type FakeWorld } from './world.ts';
1
+ import { type FakePluginSeed, type FakeWorld } from './world.ts';
2
2
  export type FakeHerdrOptions = {
3
3
  dir: string;
4
4
  socketPath?: string;
5
5
  world?: {
6
6
  cwd?: string;
7
7
  kinds?: readonly string[];
8
+ seed?: number;
9
+ plugins?: readonly FakePluginSeed[];
8
10
  };
11
+ agentStartFaults?: AgentStartFault[];
9
12
  onStop?: () => void;
10
13
  };
14
+ export type AgentStartFault = {
15
+ code: string;
16
+ message?: string;
17
+ };
11
18
  export type FakeHerdr = {
12
19
  socketPath: string;
13
20
  bin: string;
14
21
  world: FakeWorld;
22
+ agentStartFaults: AgentStartFault[];
15
23
  emit(event: {
16
24
  type: string;
17
25
  } & Record<string, unknown>): void;
18
26
  setStatus(paneId: string, status: string): void;
27
+ watching(): string[];
28
+ watching(paneId: string): boolean;
29
+ snapshotCount(): number;
30
+ holdSnapshot(): () => void;
31
+ failNextSnapshot(code?: string, message?: string): void;
32
+ holdAck(): () => void;
33
+ failNextAck(code?: string, message?: string): void;
34
+ subscriptionCount(): number;
19
35
  stop(): Promise<void>;
20
36
  };
21
37
  export declare function startFakeHerdr(options: FakeHerdrOptions): Promise<FakeHerdr>;