@boxxy/server 0.1.3 → 0.1.5

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,6 +1,8 @@
1
+ import type { WatchReport } from '@boxxy/dispatch';
1
2
  import type { FakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
2
3
  import type { TestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
3
- import type { AttemptRow, CallerProof, Clock, PhaseName } from '@boxxy/model';
4
+ import type { ClosableClaimStore } from '@boxxy/dispatch/test-utils/types';
5
+ import type { AttemptRow, CallerProof, Clock, PepysReader, PhaseName } from '@boxxy/model';
4
6
  import type { FakeNotifier } from '@boxxy/model/test-utils/create-fake-notifier';
5
7
  import type { FakePepysReader } from '@boxxy/model/test-utils/create-fake-pepys-reader';
6
8
  import type { TickReport } from '../tick/run-tick.ts';
@@ -9,6 +11,8 @@ import type { FakeCallerProof } from './create-fake-caller-proof.ts';
9
11
  export interface TestServerOptions {
10
12
  readonly hasPepys?: boolean;
11
13
  readonly makeCallerProof?: (clock: Clock) => Promise<CallerProof>;
14
+ readonly pepys?: PepysReader;
15
+ readonly store?: ClosableClaimStore;
12
16
  }
13
17
  export interface TestServer {
14
18
  readonly dispatch: TestDispatch;
@@ -25,6 +29,7 @@ export interface TestServer {
25
29
  readonly owner: (request: Request) => Promise<Response>;
26
30
  readonly worker: (request: Request) => Promise<Response>;
27
31
  readonly tick: () => Promise<TickReport>;
32
+ readonly watch: () => Promise<WatchReport>;
28
33
  readonly seedStartedAttempt: (phase: PhaseName) => Promise<AttemptRow>;
29
34
  }
30
35
  export declare function createTestServer(options?: TestServerOptions): Promise<TestServer>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/server",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The boxxy server: the API behind its three doors, and the runner loop",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/apps/server#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
@@ -32,19 +32,19 @@
32
32
  "typecheck": "tsrs --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@boxxy/caller-proof-imp": "0.1.3",
36
- "@boxxy/closeout": "0.1.3",
37
- "@boxxy/content-hash": "0.1.3",
38
- "@boxxy/contract": "0.1.3",
39
- "@boxxy/dispatch": "0.1.3",
40
- "@boxxy/forge-github": "0.1.3",
41
- "@boxxy/model": "0.1.3",
42
- "@boxxy/notifier-atc": "0.1.3",
43
- "@boxxy/pepys": "0.1.3",
44
- "@boxxy/planning": "0.1.3",
45
- "@boxxy/rules": "0.1.3",
46
- "@boxxy/store-sqlite": "0.1.3",
47
- "@boxxy/worker-atc": "0.1.3",
35
+ "@boxxy/caller-proof-imp": "0.1.5",
36
+ "@boxxy/closeout": "0.1.5",
37
+ "@boxxy/content-hash": "0.1.5",
38
+ "@boxxy/contract": "0.1.5",
39
+ "@boxxy/dispatch": "0.1.5",
40
+ "@boxxy/forge-github": "0.1.5",
41
+ "@boxxy/model": "0.1.5",
42
+ "@boxxy/notifier-atc": "0.1.5",
43
+ "@boxxy/pepys": "0.1.5",
44
+ "@boxxy/planning": "0.1.5",
45
+ "@boxxy/rules": "0.1.5",
46
+ "@boxxy/store-sqlite": "0.1.5",
47
+ "@boxxy/worker-atc": "0.1.5",
48
48
  "@modelcontextprotocol/server": "2.4.0",
49
49
  "@orpc/client": "2.0.0-beta.50",
50
50
  "@orpc/mcp": "2.0.0-beta.50",
@@ -1,25 +1,36 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  export type IssuersAnswer =
5
5
  | { readonly kind: 'read'; readonly issuers: readonly unknown[] }
6
6
  | { readonly kind: 'invalid'; readonly message: string };
7
7
 
8
- // The trust documents, one per file of each folder, as parsed JSON in name order. The adapter
9
- // checks their shape; this reads them only.
8
+ // The trust documents, one per regular file of each folder, as parsed JSON in name order. The
9
+ // adapter checks their shape; this reads them only. Folders that hold no document refuse, so a
10
+ // configured but empty mount never starts a server that refuses every session in silence.
10
11
  export async function readTrustedIssuers(folders: readonly string[]): Promise<IssuersAnswer> {
11
12
  const issuers: unknown[] = [];
12
13
 
13
14
  for (const folder of folders) {
14
- const files = await readFileNames(folder);
15
+ const names = await readEntryNames(folder);
15
16
 
16
- if (files === null) {
17
+ if (names === null) {
17
18
  return { kind: 'invalid', message: `no trusted issuers folder at ${folder}` };
18
19
  }
19
20
 
20
- for (const name of files) {
21
+ for (const name of names) {
21
22
  const file = path.join(folder, name);
22
23
 
24
+ const entry = await readEntryKind(file);
25
+
26
+ if (entry === 'missing') {
27
+ return { kind: 'invalid', message: `the trust document at ${file} points nowhere` };
28
+ }
29
+
30
+ if (entry === 'other') {
31
+ continue;
32
+ }
33
+
23
34
  const text = await Bun.file(file).text();
24
35
 
25
36
  const json = parseJSON(text);
@@ -32,23 +43,38 @@ export async function readTrustedIssuers(folders: readonly string[]): Promise<Is
32
43
  }
33
44
  }
34
45
 
46
+ if (issuers.length === 0) {
47
+ return { kind: 'invalid', message: `no trust document in ${folders.join(', ')}` };
48
+ }
49
+
35
50
  return { kind: 'read', issuers };
36
51
  }
37
52
 
38
- // null: the folder does not exist
39
- async function readFileNames(folder: string): Promise<string[] | null> {
53
+ // null: the folder does not exist. A Kubernetes volume keeps its real files under `..data` and
54
+ // `..<timestamp>` and shows each key as a symlink beside them, so `..` names are skipped.
55
+ async function readEntryNames(folder: string): Promise<string[] | null> {
40
56
  try {
41
- const entries = await readdir(folder, { withFileTypes: true });
57
+ const names = await readdir(folder);
42
58
 
43
- return entries
44
- .filter((entry) => entry.isFile())
45
- .map((entry) => entry.name)
59
+ return names
60
+ .filter((name) => !name.startsWith('..'))
46
61
  .toSorted((left, right) => left.localeCompare(right));
47
62
  } catch {
48
63
  return null;
49
64
  }
50
65
  }
51
66
 
67
+ // `stat` follows a symlink to its target; a symlink that points nowhere is `missing`
68
+ async function readEntryKind(file: string): Promise<'file' | 'other' | 'missing'> {
69
+ try {
70
+ const stats = await stat(file);
71
+
72
+ return stats.isFile() ? 'file' : 'other';
73
+ } catch {
74
+ return 'missing';
75
+ }
76
+ }
77
+
52
78
  function parseJSON(text: string): unknown {
53
79
  try {
54
80
  const value: unknown = JSON.parse(text);
@@ -1,12 +1,16 @@
1
+ import { watchPullRequests } from '@boxxy/dispatch';
2
+ import type { WatchReport } from '@boxxy/dispatch';
1
3
  import type { FakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
2
4
  import { createFakeForge } from '@boxxy/dispatch/test-utils/create-fake-forge';
3
5
  import type { TestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
4
6
  import { createTestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
7
+ import type { ClosableClaimStore } from '@boxxy/dispatch/test-utils/types';
5
8
  import type {
6
9
  AttemptId,
7
10
  AttemptRow,
8
11
  CallerProof,
9
12
  Clock,
13
+ PepysReader,
10
14
  PhaseName,
11
15
  SessionReading,
12
16
  } from '@boxxy/model';
@@ -33,6 +37,12 @@ export interface TestServerOptions {
33
37
  // the server's real proof in place of the fake one, built on the test's clock; the fake that
34
38
  // `callerProof` returns then proves nothing
35
39
  readonly makeCallerProof?: (clock: Clock) => Promise<CallerProof>;
40
+
41
+ // the reader of a pepys under test in place of the fake one, which `pepys` then still returns
42
+ readonly pepys?: PepysReader;
43
+
44
+ // the memory store when left out
45
+ readonly store?: ClosableClaimStore;
36
46
  }
37
47
 
38
48
  export interface TestServer {
@@ -57,6 +67,9 @@ export interface TestServer {
57
67
  // one runner tick at the clock's time
58
68
  readonly tick: () => Promise<TickReport>;
59
69
 
70
+ // one pass of the pull request watch at the clock's time
71
+ readonly watch: () => Promise<WatchReport>;
72
+
60
73
  // an item at the phase, claimed and started by the fake worker; its row carries the caller
61
74
  // that its session reports
62
75
  readonly seedStartedAttempt: (phase: PhaseName) => Promise<AttemptRow>;
@@ -65,7 +78,9 @@ export interface TestServer {
65
78
  // The server's two listeners on the memory store, the fake worker, the fake clock, a fake pepys
66
79
  // reader, a fake notifier and a fake forge, with the example rules. No port is opened.
67
80
  export async function createTestServer(options: TestServerOptions = {}): Promise<TestServer> {
68
- const dispatch = await createTestDispatch();
81
+ const dispatchOptions = options.store === undefined ? {} : { store: options.store };
82
+
83
+ const dispatch = await createTestDispatch(dispatchOptions);
69
84
 
70
85
  const callerProof = createFakeCallerProof();
71
86
  const pepys = createFakePepysReader();
@@ -95,7 +110,7 @@ export async function createTestServer(options: TestServerOptions = {}): Promise
95
110
  ? callerProof
96
111
  : await options.makeCallerProof(dispatch.clock),
97
112
  workerTokenDigest: buildTokenDigest(workerToken),
98
- pepys: options.hasPepys === false ? null : pepys,
113
+ pepys: options.hasPepys === false ? null : (options.pepys ?? pepys),
99
114
  forge,
100
115
  outbox: createOffOutbox(dispatch.store, dispatch.clock),
101
116
  readings,
@@ -129,6 +144,15 @@ export async function createTestServer(options: TestServerOptions = {}): Promise
129
144
  readings,
130
145
  log,
131
146
  }),
147
+ watch: () =>
148
+ watchPullRequests({
149
+ store: dispatch.store,
150
+ forge,
151
+ rules: dispatch.rules(),
152
+ runner: dispatch.runner,
153
+ now: dispatch.clock.now(),
154
+ newId: dispatch.newId,
155
+ }),
132
156
  seedStartedAttempt: async (phase) => {
133
157
  await dispatch.seedItem({ phase });
134
158