@re-cinq/floor-client 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -54,6 +54,8 @@ There is no idle timeout here on purpose. A caught-up quiet run legitimately say
54
54
 
55
55
  One caveat for a relay: `visit_opened` and `visit_reported` carry the whole visit, `agentSettings` included — the model, the prompt, the image. Filter those before they reach a browser.
56
56
 
57
+ `runs.watchFloor(options)` follows the whole floor instead of one run: `run_started` and `run_changed`, each with a run's id and nothing else. It has no cursor. The floor opens every connection with `resync`, which means "read your list again", so a watch that drops comes back with nothing to remember. It is refused without the service token and comes back from everything else.
58
+
57
59
  ## Testing against it
58
60
 
59
61
  Both seams are injectable and neither needs a server: `fetchFn` for HTTP, `socketFn` for the live socket. With `backoffMs: () => 0`, the whole reconnect table is unit-testable in milliseconds — see `live.test.ts`.
package/dist/filters.d.ts CHANGED
@@ -7,6 +7,7 @@ export type RunFilter = OneOf<{
7
7
  repo: string;
8
8
  subject: string;
9
9
  open: boolean;
10
+ since: string;
10
11
  }>;
11
12
  export type EventFilter = OneOf<{
12
13
  since: string;
@@ -0,0 +1,14 @@
1
+ import type { FloorFrame } from "@re-cinq/floor-contracts";
2
+ import { type LiveReachable, type WatchEnd } from "./live.js";
3
+ export interface FloorWatchOptions {
4
+ /** False leaves every close to the caller. */
5
+ reconnect?: boolean;
6
+ /** Attempt to delay. Return 0 in a test. */
7
+ backoffMs?: (attempt: number) => number;
8
+ signal?: AbortSignal;
9
+ }
10
+ export interface FloorWatch extends AsyncIterable<FloorFrame> {
11
+ readonly ended: Promise<WatchEnd>;
12
+ stop(): void;
13
+ }
14
+ export declare function watchFloor(floor: LiveReachable, options?: FloorWatchOptions): FloorWatch;
@@ -0,0 +1,8 @@
1
+ import { follow, openFeed } from "./live.js";
2
+ export function watchFloor(floor, options = {}) {
3
+ const watch = openFeed();
4
+ options.signal?.addEventListener("abort", () => watch.stop());
5
+ void follow({ floor, options, watch, urlFor: () => `${floor.url.replace(/^http/, "ws")}/assembly-runs/live` });
6
+ return watch;
7
+ }
8
+ //# sourceMappingURL=floor-live.js.map
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { serviceToken, visitToken } from "./tokens.js";
4
4
  export { FloorProblem, isFloorProblem } from "./problem.js";
5
5
  export { reach, asked, send } from "./send.js";
6
6
  export { CLOSE, watchRun } from "./live.js";
7
+ export { watchFloor } from "./floor-live.js";
7
8
  export type { FloorAccess, FloorClient, HealthApi, LinesApi, MigrationsApi, SchedulesApi, Version } from "./client.js";
8
9
  export type { AppendRecord, GitCredentialOutcome, VisitAccess, VisitClient } from "./visit.js";
9
10
  export type { ServiceToken, VisitToken } from "./tokens.js";
@@ -14,5 +15,6 @@ export type { EnqueueEvent, EventsApi, EventsPage } from "./events.js";
14
15
  export type { CostsFilter, CostsGroupBy, EventFilter, OneOf, RunFilter } from "./filters.js";
15
16
  export type { CostsRowsApi, RunPage, RunsApi, StartRun, StartedRun } from "./runs.js";
16
17
  export type { BriefOutcome, RecordsPage, StationRunsApi } from "./station-runs.js";
18
+ export type { FloorWatch, FloorWatchOptions } from "./floor-live.js";
17
19
  export type { LiveSocket, RefusalCode, RunWatch, SocketFn, WatchEnd, WatchOptions } from "./live.js";
18
20
  export type * from "@re-cinq/floor-contracts";
package/dist/index.js CHANGED
@@ -5,4 +5,5 @@ export { serviceToken, visitToken } from "./tokens.js";
5
5
  export { FloorProblem, isFloorProblem } from "./problem.js";
6
6
  export { reach, asked, send } from "./send.js";
7
7
  export { CLOSE, watchRun } from "./live.js";
8
+ export { watchFloor } from "./floor-live.js";
8
9
  //# sourceMappingURL=index.js.map
package/dist/live.d.ts CHANGED
@@ -54,4 +54,26 @@ export interface RunWatch extends AsyncIterable<LiveFrame> {
54
54
  stop(): void;
55
55
  }
56
56
  export declare function watchRun(floor: LiveReachable, runId: string, options?: WatchOptions): RunWatch;
57
+ /** What one watch holds while it runs: the frames not yet handed out, the socket, and whether it is over. */
58
+ export interface Feed<Frame> extends AsyncIterable<Frame> {
59
+ readonly waiting: Frame[];
60
+ readonly socket: {
61
+ current: LiveSocket | null;
62
+ };
63
+ readonly ended: Promise<WatchEnd>;
64
+ endedYet(): WatchEnd | null;
65
+ take(frame: Frame): void;
66
+ settle(end: WatchEnd): void;
67
+ stop(): void;
68
+ }
69
+ export declare function openFeed<Frame>(onTake?: (frame: Frame) => void): Feed<Frame>;
70
+ type Reconnecting = Pick<WatchOptions, "reconnect" | "backoffMs">;
71
+ interface Following<Frame> {
72
+ floor: LiveReachable;
73
+ options: Reconnecting;
74
+ watch: Feed<Frame>;
75
+ /** The address for this attempt: a run's moves with its cursor, the floor's does not. */
76
+ urlFor(): string;
77
+ }
78
+ export declare function follow<Frame>(following: Following<Frame>): Promise<void>;
57
79
  export {};
package/dist/live.js CHANGED
@@ -4,36 +4,26 @@ const REFUSALS = [CLOSE.badCursor, CLOSE.noServiceToken, CLOSE.noSuchRun, CLOSE.
4
4
  const BACKOFF_START_MS = 250;
5
5
  const BACKOFF_CAP_MS = 10_000;
6
6
  export function watchRun(floor, runId, options = {}) {
7
- const watch = openWatch(cursorOf(options.after));
8
- options.signal?.addEventListener("abort", () => watch.stop());
9
- void follow({ floor, runId, options, watch });
10
- return watch;
11
- }
12
- function openWatch(from) {
13
- const journal = journalFrom(from);
14
- const socket = { current: null };
15
- const reading = journal.reading;
7
+ const cursor = { seq: cursorOf(options.after) };
8
+ const feed = openFeed((frame) => {
9
+ if (frame.type !== "unsupported")
10
+ cursor.seq = frame.seq;
11
+ });
12
+ options.signal?.addEventListener("abort", () => feed.stop());
13
+ void follow({ floor, options, watch: feed, urlFor: () => liveUrl(floor.url, runId, cursor.seq) });
16
14
  return {
17
15
  get seq() {
18
- return reading.seq;
19
- },
20
- waiting: reading.waiting,
21
- ended: reading.ended,
22
- endedYet: reading.endedYet,
23
- settle: reading.settle,
24
- take: reading.take,
25
- socket,
26
- stop: () => {
27
- socket.current?.close(CLOSE.settled, "the watcher stopped");
28
- reading.settle({ reason: "stopped" });
16
+ return cursor.seq;
29
17
  },
30
- [Symbol.asyncIterator]: () => reading[Symbol.asyncIterator](),
18
+ ended: feed.ended,
19
+ stop: feed.stop,
20
+ [Symbol.asyncIterator]: () => feed[Symbol.asyncIterator](),
31
21
  };
32
22
  }
33
- /** What has arrived and not yet been handed out, the cursor it moved, and whether the watch is over. */
34
- function journalFrom(from) {
35
- const held = { seq: from, end: null };
23
+ export function openFeed(onTake = () => { }) {
24
+ const held = { end: null };
36
25
  const waiting = [];
26
+ const socket = { current: null };
37
27
  const { sleep, wake } = sleeper();
38
28
  const { ended, finish } = promised();
39
29
  const settle = (end) => {
@@ -44,22 +34,21 @@ function journalFrom(from) {
44
34
  wake();
45
35
  };
46
36
  return {
47
- reading: {
48
- get seq() {
49
- return held.seq;
50
- },
51
- waiting,
52
- ended,
53
- endedYet: () => held.end,
54
- settle,
55
- take: (frame) => {
56
- if (frame.type !== "unsupported")
57
- held.seq = frame.seq;
58
- waiting.push(frame);
59
- wake();
60
- },
61
- [Symbol.asyncIterator]: () => handOut(waiting, { endedYet: () => held.end, sleep }),
37
+ waiting,
38
+ ended,
39
+ socket,
40
+ endedYet: () => held.end,
41
+ settle,
42
+ take: (frame) => {
43
+ onTake(frame);
44
+ waiting.push(frame);
45
+ wake();
46
+ },
47
+ stop: () => {
48
+ socket.current?.close(CLOSE.settled, "the watcher stopped");
49
+ settle({ reason: "stopped" });
62
50
  },
51
+ [Symbol.asyncIterator]: () => handOut(waiting, { endedYet: () => held.end, sleep }),
63
52
  };
64
53
  }
65
54
  /** A reader waiting on the next frame, woken when one arrives or when the watch is over. */
@@ -95,7 +84,7 @@ function handOut(waiting, watch) {
95
84
  },
96
85
  };
97
86
  }
98
- async function follow(following) {
87
+ export async function follow(following) {
99
88
  const { options, watch } = following;
100
89
  let attempt = 0;
101
90
  while (!watch.endedYet()) {
@@ -120,7 +109,7 @@ function endFor(closed, options) {
120
109
  function connected(following) {
121
110
  const { floor, watch } = following;
122
111
  const open = floor.socketFn ?? defaultSocket;
123
- const socket = open(liveUrl(floor.url, following.runId, watch.seq), { headers: { authorization: `Bearer ${floor.token}` } });
112
+ const socket = open(following.urlFor(), { headers: { authorization: `Bearer ${floor.token}` } });
124
113
  watch.socket.current = socket;
125
114
  return new Promise((resolve) => {
126
115
  socket.onFrame((said) => {
package/dist/runs.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CostsRow, Item, RunView } from "@re-cinq/floor-contracts";
2
2
  import type { CostsFilter, CostsGroupBy, RunFilter } from "./filters.js";
3
3
  import { type Reachable } from "./send.js";
4
+ import { type FloorWatch, type FloorWatchOptions } from "./floor-live.js";
4
5
  import { type RunWatch, type WatchOptions } from "./live.js";
5
6
  export interface StartRun {
6
7
  repo: string;
@@ -31,6 +32,8 @@ export interface RunsApi {
31
32
  cancel(runId: string, reason: string): Promise<RunView>;
32
33
  /** Replays the run from `after`, then follows it live. Reconnects from the last seq it handed out. */
33
34
  watch(runId: string, options?: WatchOptions): RunWatch;
35
+ /** Follows the whole floor: which runs start and change, by id. `resync` comes first and after any gap. */
36
+ watchFloor(options?: FloorWatchOptions): FloorWatch;
34
37
  }
35
38
  export declare function runsApi(floor: Reachable): RunsApi;
36
39
  export interface CostsRowsApi {
package/dist/runs.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { asked } from "./send.js";
2
+ import { watchFloor } from "./floor-live.js";
2
3
  import { watchRun } from "./live.js";
3
4
  const HTTP_NOT_FOUND = 404;
4
5
  export function runsApi(floor) {
@@ -7,6 +8,7 @@ export function runsApi(floor) {
7
8
  get: (runId) => asked(floor, { method: "GET", path: `/assembly-runs/${runId}` }, [HTTP_NOT_FOUND]),
8
9
  cancel: async (runId, reason) => (await asked(floor, { method: "POST", path: `/assembly-runs/${runId}/cancel`, body: { reason } })),
9
10
  watch: (runId, options) => watchRun(floor, runId, options),
11
+ watchFloor: (options) => watchFloor(floor, options),
10
12
  };
11
13
  }
12
14
  export function costsApi(floor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@re-cinq/floor-client",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "The floor over HTTP and its live socket, typed: definitions, runs, visits, blobs, events, schedules and costs, plus a watch that replays a run from a cursor and reconnects without losing a frame.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@re-cinq/floor-contracts": "0.1.4"
48
+ "@re-cinq/floor-contracts": "0.1.6"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.20.1",