@boxxy/server 0.0.1 → 0.1.4
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 +57 -5
- package/dist/auth/authorize-call.d.ts +11 -0
- package/dist/auth/is-browser-request.d.ts +1 -0
- package/dist/auth/pick-request-scope.d.ts +9 -0
- package/dist/config/pick-server-secrets.d.ts +16 -0
- package/dist/config/read-caller-proof.d.ts +10 -0
- package/dist/config/read-server-config.d.ts +9 -0
- package/dist/config/read-trusted-issuers.d.ts +8 -0
- package/dist/config/server-config-schema.d.ts +42 -0
- package/dist/create-capacity-backoff.d.ts +2 -0
- package/dist/handlers/api.d.ts +1683 -0
- package/dist/handlers/attempt-handlers.d.ts +130 -0
- package/dist/handlers/build-owner-fact.d.ts +3 -0
- package/dist/handlers/item-handlers.d.ts +168 -0
- package/dist/handlers/outbox-handlers.d.ts +7 -0
- package/dist/handlers/plan-handlers.d.ts +62 -0
- package/dist/handlers/read-item-ids.d.ts +10 -0
- package/dist/handlers/relation-handlers.d.ts +41 -0
- package/dist/handlers/report-handlers.d.ts +102 -0
- package/dist/handlers/rules-handlers.d.ts +20 -0
- package/dist/handlers/runner-handlers.d.ts +65 -0
- package/dist/handlers/to-wire-json.d.ts +5 -0
- package/dist/handlers/write-levers.d.ts +11 -0
- package/dist/index.d.ts +12 -0
- package/dist/main.d.ts +1 -0
- package/dist/no-caller-proof.d.ts +2 -0
- package/dist/outbox/create-off-outbox.d.ts +3 -0
- package/dist/outbox/start-outbox-sender.d.ts +10 -0
- package/dist/read-limited-request.d.ts +1 -0
- package/dist/rules/create-rules-holder.d.ts +20 -0
- package/dist/rules/load-server-rules.d.ts +7 -0
- package/dist/rules/read-rules-checkout.d.ts +9 -0
- package/dist/serve-api.d.ts +50 -0
- package/dist/start-server.d.ts +18 -0
- package/dist/test-utils/build-mcp-request.d.ts +8 -0
- package/dist/test-utils/build-rpc-request.d.ts +7 -0
- package/dist/test-utils/build-session-input.d.ts +1 -0
- package/dist/test-utils/create-fake-caller-proof.d.ts +5 -0
- package/dist/test-utils/create-test-server.d.ts +35 -0
- package/dist/test-utils/list-owner-procedures.d.ts +5 -0
- package/dist/test-utils/read-mcp-answer.d.ts +11 -0
- package/dist/test-utils/wait-for.d.ts +1 -0
- package/dist/tick/run-tick.d.ts +24 -0
- package/dist/tick/start-tick-loop.d.ts +12 -0
- package/dist/types.d.ts +41 -0
- package/package.json +48 -4
- package/src/auth/authorize-call.ts +50 -0
- package/src/auth/is-browser-request.ts +8 -0
- package/src/auth/pick-request-scope.ts +37 -0
- package/src/config/pick-server-secrets.ts +48 -0
- package/src/config/read-caller-proof.ts +36 -0
- package/src/config/read-server-config.ts +41 -0
- package/src/config/read-trusted-issuers.ts +60 -0
- package/src/config/server-config-schema.ts +71 -0
- package/src/create-capacity-backoff.ts +18 -0
- package/src/handlers/api.ts +23 -0
- package/src/handlers/attempt-handlers.ts +244 -0
- package/src/handlers/build-owner-fact.ts +20 -0
- package/src/handlers/item-handlers.ts +462 -0
- package/src/handlers/outbox-handlers.ts +15 -0
- package/src/handlers/plan-handlers.ts +75 -0
- package/src/handlers/read-item-ids.ts +31 -0
- package/src/handlers/relation-handlers.ts +143 -0
- package/src/handlers/report-handlers.ts +112 -0
- package/src/handlers/rules-handlers.ts +46 -0
- package/src/handlers/runner-handlers.ts +117 -0
- package/src/handlers/to-wire-json.ts +20 -0
- package/src/handlers/write-levers.ts +53 -0
- package/src/index.ts +12 -0
- package/src/main.ts +31 -0
- package/src/no-caller-proof.ts +7 -0
- package/src/outbox/create-off-outbox.ts +21 -0
- package/src/outbox/start-outbox-sender.ts +89 -0
- package/src/read-limited-request.ts +43 -0
- package/src/rules/create-rules-holder.ts +107 -0
- package/src/rules/load-server-rules.ts +24 -0
- package/src/rules/read-rules-checkout.ts +65 -0
- package/src/serve-api.ts +178 -0
- package/src/start-server.ts +308 -0
- package/src/test-utils/build-mcp-request.ts +34 -0
- package/src/test-utils/build-rpc-request.ts +23 -0
- package/src/test-utils/build-session-input.ts +16 -0
- package/src/test-utils/create-fake-caller-proof.ts +18 -0
- package/src/test-utils/create-test-server.ts +166 -0
- package/src/test-utils/list-owner-procedures.ts +38 -0
- package/src/test-utils/read-mcp-answer.ts +26 -0
- package/src/test-utils/wait-for.ts +14 -0
- package/src/tick/run-tick.ts +212 -0
- package/src/tick/start-tick-loop.ts +46 -0
- package/src/types.ts +86 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ItemId, RelationRow, RelationTypeName } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import { api } from './api.ts';
|
|
4
|
+
import { buildOwnerFact } from './build-owner-fact.ts';
|
|
5
|
+
import { readItemIds } from './read-item-ids.ts';
|
|
6
|
+
|
|
7
|
+
// The owner's relation commands. A refusal that the owner acts on is an answer, not an error.
|
|
8
|
+
export const relationHandlers = {
|
|
9
|
+
add: api.relation.add.handler((options) => {
|
|
10
|
+
const input = options.input;
|
|
11
|
+
const notFound = options.errors.NOT_FOUND;
|
|
12
|
+
const deps = options.context.deps;
|
|
13
|
+
const rules = deps.rulesState().rules;
|
|
14
|
+
|
|
15
|
+
return deps.store.exclusive(async (tx) => {
|
|
16
|
+
const found = await readItemIds(tx, deps, [input.source, input.target]);
|
|
17
|
+
|
|
18
|
+
if (found.kind === 'not-found') {
|
|
19
|
+
throw notFound({ data: { item: found.item } });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const [source, target] = found.items;
|
|
23
|
+
const type = brandSchemas.relationTypeName.safeParse(input.type);
|
|
24
|
+
|
|
25
|
+
if (source === undefined || target === undefined) {
|
|
26
|
+
throw notFound({ data: { item: input.source } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!type.success || !rules.relationTypes.has(type.data)) {
|
|
30
|
+
return { kind: 'refused', reason: 'unknown-type' } as const;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const now = deps.clock.now();
|
|
34
|
+
|
|
35
|
+
const snapshot = await tx.readSnapshot(now, rules);
|
|
36
|
+
|
|
37
|
+
const refusal = pickRelationRefusal(snapshot.relations, source, target, type.data);
|
|
38
|
+
|
|
39
|
+
if (refusal !== null) {
|
|
40
|
+
return { kind: 'refused', reason: refusal } as const;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const row: RelationRow = {
|
|
44
|
+
source,
|
|
45
|
+
target,
|
|
46
|
+
type: type.data,
|
|
47
|
+
addedBy: 'boxxy:owner:local',
|
|
48
|
+
addedAt: now,
|
|
49
|
+
removedAt: null,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await tx.applyChange({
|
|
53
|
+
at: now,
|
|
54
|
+
writes: [{ op: 'insert', table: 'relations', row }],
|
|
55
|
+
facts: [
|
|
56
|
+
buildOwnerFact(deps, source, 'boxxy.relation.added', { source, target, type: type.data }),
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return { kind: 'added' } as const;
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
remove: api.relation.remove.handler((options) => {
|
|
64
|
+
const input = options.input;
|
|
65
|
+
const notFound = options.errors.NOT_FOUND;
|
|
66
|
+
const deps = options.context.deps;
|
|
67
|
+
|
|
68
|
+
return deps.store.exclusive(async (tx) => {
|
|
69
|
+
const found = await readItemIds(tx, deps, [input.source, input.target]);
|
|
70
|
+
|
|
71
|
+
if (found.kind === 'not-found') {
|
|
72
|
+
throw notFound({ data: { item: found.item } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const now = deps.clock.now();
|
|
76
|
+
|
|
77
|
+
const snapshot = await tx.readSnapshot(now, deps.rulesState().rules);
|
|
78
|
+
|
|
79
|
+
const live = snapshot.relations.find(
|
|
80
|
+
(row) =>
|
|
81
|
+
row.source === input.source && row.target === input.target && row.type === input.type,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (live === undefined) {
|
|
85
|
+
return { removed: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const key = { source: live.source, target: live.target, type: live.type };
|
|
89
|
+
|
|
90
|
+
await tx.applyChange({
|
|
91
|
+
at: now,
|
|
92
|
+
writes: [
|
|
93
|
+
{
|
|
94
|
+
op: 'update',
|
|
95
|
+
table: 'relations',
|
|
96
|
+
key,
|
|
97
|
+
expect: { removedAt: null },
|
|
98
|
+
set: { removedAt: now },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
facts: [buildOwnerFact(deps, live.source, 'boxxy.relation.removed', key)],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { removed: true };
|
|
105
|
+
});
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// `exists`: the same live relation is there. `cycle`: the target already reaches the source
|
|
110
|
+
// through relations of the type, so the new one would close a loop.
|
|
111
|
+
function pickRelationRefusal(
|
|
112
|
+
relations: readonly RelationRow[],
|
|
113
|
+
source: ItemId,
|
|
114
|
+
target: ItemId,
|
|
115
|
+
type: RelationTypeName,
|
|
116
|
+
): 'exists' | 'cycle' | null {
|
|
117
|
+
const ofType = relations.filter((row) => row.type === type);
|
|
118
|
+
|
|
119
|
+
if (ofType.some((row) => row.source === source && row.target === target)) {
|
|
120
|
+
return 'exists';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return isReachable(ofType, target, source) ? 'cycle' : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isReachable(relations: readonly RelationRow[], from: ItemId, to: ItemId): boolean {
|
|
127
|
+
const seen = new Set<ItemId>();
|
|
128
|
+
|
|
129
|
+
const queue: ItemId[] = [from];
|
|
130
|
+
|
|
131
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
132
|
+
if (next === to) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!seen.has(next)) {
|
|
137
|
+
seen.add(next);
|
|
138
|
+
queue.push(...relations.filter((row) => row.source === next).map((row) => row.target));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ItemId, PepysAnswer, PepysReader } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import type { ServerDeps } from '../types.ts';
|
|
4
|
+
import { api } from './api.ts';
|
|
5
|
+
|
|
6
|
+
// The two reads of pepys. A server with no pepys section answers PEPYS_NOT_CONFIGURED; a pepys
|
|
7
|
+
// that does not answer is PEPYS_UNAVAILABLE. `behind` counts the item's facts that the outbox has
|
|
8
|
+
// not sent, from boxxy's own store.
|
|
9
|
+
export const reportHandlers = {
|
|
10
|
+
cost: api.report.cost.handler(async (options) => {
|
|
11
|
+
const item = options.input.item;
|
|
12
|
+
|
|
13
|
+
const report = await readReport(options.context.deps, item, (pepys, id) =>
|
|
14
|
+
pepys.readCostToLand(id),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return report.kind === 'value'
|
|
18
|
+
? { ...report.value, item, behind: report.behind }
|
|
19
|
+
: throwReportError(report, options.errors, item);
|
|
20
|
+
}),
|
|
21
|
+
stageTimes: api.report.stageTimes.handler(async (options) => {
|
|
22
|
+
const item = options.input.item;
|
|
23
|
+
|
|
24
|
+
const report = await readReport(options.context.deps, item, (pepys, id) =>
|
|
25
|
+
pepys.readStageTime(id),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return report.kind === 'value'
|
|
29
|
+
? { ...report.value, item, behind: report.behind }
|
|
30
|
+
: throwReportError(report, options.errors, item);
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type Report<Value> =
|
|
35
|
+
| {
|
|
36
|
+
readonly kind: 'value';
|
|
37
|
+
readonly value: Value;
|
|
38
|
+
readonly behind: { readonly facts: number; readonly oldestAt: number | null };
|
|
39
|
+
}
|
|
40
|
+
| { readonly kind: 'not-found' }
|
|
41
|
+
| { readonly kind: 'not-configured' }
|
|
42
|
+
| { readonly kind: 'unavailable'; readonly reason: string };
|
|
43
|
+
|
|
44
|
+
async function readReport<Value>(
|
|
45
|
+
deps: ServerDeps,
|
|
46
|
+
rawItem: string,
|
|
47
|
+
read: (pepys: PepysReader, item: ItemId) => Promise<PepysAnswer<Value>>,
|
|
48
|
+
): Promise<Report<Value>> {
|
|
49
|
+
const parsed = brandSchemas.itemId.safeParse(rawItem);
|
|
50
|
+
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
return { kind: 'not-found' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const item = parsed.data;
|
|
56
|
+
const now = deps.clock.now();
|
|
57
|
+
const rules = deps.rulesState().rules;
|
|
58
|
+
|
|
59
|
+
const [view, behind] = await deps.store.read((tx) =>
|
|
60
|
+
Promise.all([tx.readItem(item, now, rules), tx.readItemBehind(item)]),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (view === null) {
|
|
64
|
+
return { kind: 'not-found' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (deps.pepys === null) {
|
|
68
|
+
return { kind: 'not-configured' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const answer = await read(deps.pepys, item);
|
|
72
|
+
|
|
73
|
+
return answer.kind === 'value'
|
|
74
|
+
? { kind: 'value', value: answer.value, behind }
|
|
75
|
+
: { kind: 'unavailable', reason: answer.reason };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface NotFoundOptions {
|
|
79
|
+
readonly data: { readonly item: string };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface UnavailableOptions {
|
|
83
|
+
readonly data: { readonly reason: string };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ReportErrors {
|
|
87
|
+
readonly NOT_FOUND: (options: NotFoundOptions) => Error;
|
|
88
|
+
readonly PEPYS_NOT_CONFIGURED: () => Error;
|
|
89
|
+
readonly PEPYS_UNAVAILABLE: (options: UnavailableOptions) => Error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The contract's error constructors carry the contract's message, which a bare `ORPCError` would
|
|
93
|
+
// drop; a lowercase name keeps the call clear of the `new-cap` lint.
|
|
94
|
+
function throwReportError(
|
|
95
|
+
report: Exclude<Report<unknown>, { kind: 'value' }>,
|
|
96
|
+
errors: ReportErrors,
|
|
97
|
+
item: string,
|
|
98
|
+
): never {
|
|
99
|
+
const notFound = errors.NOT_FOUND;
|
|
100
|
+
const notConfigured = errors.PEPYS_NOT_CONFIGURED;
|
|
101
|
+
const unavailable = errors.PEPYS_UNAVAILABLE;
|
|
102
|
+
|
|
103
|
+
if (report.kind === 'not-found') {
|
|
104
|
+
throw notFound({ data: { item } });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (report.kind === 'not-configured') {
|
|
108
|
+
throw notConfigured();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw unavailable({ data: { reason: report.reason } });
|
|
112
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { collectRetries, collectSettled } from '@boxxy/model';
|
|
2
|
+
import { dryRunRules } from '@boxxy/rules';
|
|
3
|
+
import { api } from './api.ts';
|
|
4
|
+
|
|
5
|
+
// The rules dry run loads the given rules file in place of the server's own, beside the server's
|
|
6
|
+
// prompts, and folds each open item under both rules sets. It writes nothing.
|
|
7
|
+
export const rulesHandlers = {
|
|
8
|
+
dryRun: api.rules.dryRun.handler(async (options) => {
|
|
9
|
+
const deps = options.context.deps;
|
|
10
|
+
const proposed = deps.loadProposedRules(options.input.rules);
|
|
11
|
+
|
|
12
|
+
if (proposed.kind === 'rejected') {
|
|
13
|
+
return { kind: 'rejected', rejections: [...proposed.rejections] } as const;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const current = deps.rulesState().rules;
|
|
17
|
+
const now = deps.clock.now();
|
|
18
|
+
|
|
19
|
+
const items = await deps.store.read(async (tx) => {
|
|
20
|
+
const open = await tx.listItems({}, now, current);
|
|
21
|
+
|
|
22
|
+
return Promise.all(
|
|
23
|
+
open
|
|
24
|
+
.filter((item) => item.closedAt === null)
|
|
25
|
+
.map(async (item) => {
|
|
26
|
+
const attempts = await tx.listItemAttempts(item.id);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
item,
|
|
30
|
+
attempts: collectSettled(attempts),
|
|
31
|
+
retries: collectRetries(item.levers),
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const lines = dryRunRules({ current, proposed: proposed.rules, items });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
kind: 'loaded',
|
|
41
|
+
version: proposed.rules.version,
|
|
42
|
+
moved: lines.filter((line) => line.moved).length,
|
|
43
|
+
items: lines,
|
|
44
|
+
} as const;
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { findWaitingSince, noticeLimits, timeouts } from '@boxxy/dispatch';
|
|
2
|
+
import type { AttemptRow, Instant, RowWrite, RunnerRow } from '@boxxy/model';
|
|
3
|
+
import { brandSchemas } from '@boxxy/model';
|
|
4
|
+
import type { ServerDeps } from '../types.ts';
|
|
5
|
+
import { api } from './api.ts';
|
|
6
|
+
|
|
7
|
+
const hourMs = 3_600_000;
|
|
8
|
+
|
|
9
|
+
// The runner's status, and its two switches. A switch writes the runner's row and no fact: the
|
|
10
|
+
// fact catalog has no runner fact.
|
|
11
|
+
export const runnerHandlers = {
|
|
12
|
+
status: api.runner.status.handler(async (options) => {
|
|
13
|
+
const deps = options.context.deps;
|
|
14
|
+
const state = deps.rulesState();
|
|
15
|
+
const now = deps.clock.now();
|
|
16
|
+
const since = now - timeouts.strayHours * hourMs;
|
|
17
|
+
|
|
18
|
+
const read = await deps.store.read(async (tx) => {
|
|
19
|
+
const live = await tx.listLiveAttempts();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
runner: await tx.readRunner(deps.runner),
|
|
23
|
+
limits: await tx.listLimits(),
|
|
24
|
+
attempts: await Promise.all(
|
|
25
|
+
live.map(async (attempt) => {
|
|
26
|
+
const item = await tx.readItem(attempt.item, now, state.rules);
|
|
27
|
+
|
|
28
|
+
return { attempt, title: item?.title ?? '' };
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
strays: await tx.listStrayAttempts(brandSchemas.instant.parse(Math.max(0, since))),
|
|
32
|
+
notices: await tx.listUnsentNotices(noticeLimits.readRows),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const outbox = await deps.outbox.status();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
paused: read.runner?.paused ?? false,
|
|
40
|
+
limits: read.limits,
|
|
41
|
+
attempts: read.attempts.map((entry) => ({
|
|
42
|
+
...entry,
|
|
43
|
+
waitingSince: findAttemptWaitingSince(deps, entry.attempt),
|
|
44
|
+
})),
|
|
45
|
+
strays: read.strays,
|
|
46
|
+
notices: { unsent: read.notices.length },
|
|
47
|
+
outbox,
|
|
48
|
+
rules: {
|
|
49
|
+
version: state.rules.version,
|
|
50
|
+
commit: state.rules.commit,
|
|
51
|
+
lastRejected:
|
|
52
|
+
state.lastRejected === null
|
|
53
|
+
? null
|
|
54
|
+
: { ...state.lastRejected, rejections: [...state.lastRejected.rejections] },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
58
|
+
pause: api.runner.pause.handler(async (options) => {
|
|
59
|
+
await writePaused(options.context.deps, true);
|
|
60
|
+
|
|
61
|
+
return { paused: true } as const;
|
|
62
|
+
}),
|
|
63
|
+
resume: api.runner.resume.handler(async (options) => {
|
|
64
|
+
await writePaused(options.context.deps, false);
|
|
65
|
+
|
|
66
|
+
return { paused: false } as const;
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function findAttemptWaitingSince(deps: ServerDeps, attempt: AttemptRow) {
|
|
71
|
+
const reading = deps.readings.get(attempt.id);
|
|
72
|
+
|
|
73
|
+
return reading === undefined
|
|
74
|
+
? null
|
|
75
|
+
: findWaitingSince({
|
|
76
|
+
attempt,
|
|
77
|
+
reading,
|
|
78
|
+
quietAfterMinutes: deps.rulesState().rules.sleep.afterQuietMinutes,
|
|
79
|
+
now: deps.clock.now(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writePaused(deps: ServerDeps, paused: boolean) {
|
|
84
|
+
return deps.store.exclusive(async (tx) => {
|
|
85
|
+
const now = deps.clock.now();
|
|
86
|
+
|
|
87
|
+
const row = await tx.readRunner(deps.runner);
|
|
88
|
+
|
|
89
|
+
const write = pickPausedWrite(row, deps, paused, now);
|
|
90
|
+
|
|
91
|
+
if (write !== null) {
|
|
92
|
+
await tx.applyChange({ at: now, writes: [write], facts: [] });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// the runner's row before its first heartbeat is inserted, so a pause holds from the start
|
|
98
|
+
function pickPausedWrite(
|
|
99
|
+
row: RunnerRow | null,
|
|
100
|
+
deps: ServerDeps,
|
|
101
|
+
paused: boolean,
|
|
102
|
+
now: Instant,
|
|
103
|
+
): RowWrite | null {
|
|
104
|
+
if (row === null) {
|
|
105
|
+
return { op: 'insert', table: 'runners', row: { id: deps.runner, heartbeatAt: now, paused } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return row.paused === paused
|
|
109
|
+
? null
|
|
110
|
+
: {
|
|
111
|
+
op: 'update',
|
|
112
|
+
table: 'runners',
|
|
113
|
+
key: { id: deps.runner },
|
|
114
|
+
expect: { paused: !paused },
|
|
115
|
+
set: { paused },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JsonValue } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// the JSON type of the contract's `z.json()` fields, whose arrays and records are mutable
|
|
4
|
+
export type WireJSON = null | boolean | number | string | WireJSON[] | { [key: string]: WireJSON };
|
|
5
|
+
|
|
6
|
+
export function toWireJSON(value: JsonValue): WireJSON {
|
|
7
|
+
if (value === null || typeof value !== 'object') {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (isJsonArray(value)) {
|
|
12
|
+
return value.map((entry) => toWireJSON(entry));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, toWireJSON(entry)]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isJsonArray(value: JsonValue): value is readonly JsonValue[] {
|
|
19
|
+
return Array.isArray(value);
|
|
20
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ItemId, LeverRow, StoreTx } from '@boxxy/model';
|
|
2
|
+
import type { ServerDeps } from '../types.ts';
|
|
3
|
+
import { buildOwnerFact } from './build-owner-fact.ts';
|
|
4
|
+
import { readItemIds } from './read-item-ids.ts';
|
|
5
|
+
|
|
6
|
+
export type LeverDraft = Pick<LeverRow, 'kind' | 'until' | 'expiresAt' | 'reason' | 'phase'>;
|
|
7
|
+
|
|
8
|
+
export type LeverAnswer =
|
|
9
|
+
| { readonly kind: 'recorded'; readonly levers: readonly number[] }
|
|
10
|
+
| { readonly kind: 'not-found'; readonly item: string };
|
|
11
|
+
|
|
12
|
+
// Records one lever per item, in one change with one `boxxy.lever.recorded` each. The store
|
|
13
|
+
// numbers the levers; the answer reads the numbers back in the same lock.
|
|
14
|
+
export function writeLevers(
|
|
15
|
+
deps: ServerDeps,
|
|
16
|
+
items: readonly string[],
|
|
17
|
+
draft: LeverDraft,
|
|
18
|
+
): Promise<LeverAnswer> {
|
|
19
|
+
return deps.store.exclusive(async (tx) => {
|
|
20
|
+
const now = deps.clock.now();
|
|
21
|
+
|
|
22
|
+
const found = await readItemIds(tx, deps, items);
|
|
23
|
+
|
|
24
|
+
if (found.kind === 'not-found') {
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rows = found.items.map((item): LeverRow => ({
|
|
29
|
+
...draft,
|
|
30
|
+
seq: 0,
|
|
31
|
+
item,
|
|
32
|
+
actor: 'boxxy:owner:local',
|
|
33
|
+
at: now,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
await tx.applyChange({
|
|
37
|
+
at: now,
|
|
38
|
+
writes: rows.map((row) => ({ op: 'insert', table: 'levers', row }) as const),
|
|
39
|
+
facts: rows.map((row) => buildOwnerFact(deps, row.item, 'boxxy.lever.recorded', row)),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const levers = await Promise.all(found.items.map((item) => readLastLeverSeq(tx, deps, item)));
|
|
43
|
+
|
|
44
|
+
return { kind: 'recorded', levers };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The item's newest lever is the one this change wrote: the lock holds every other writer off.
|
|
49
|
+
async function readLastLeverSeq(tx: StoreTx, deps: ServerDeps, item: ItemId): Promise<number> {
|
|
50
|
+
const detail = await tx.readItemDetail(item, deps.clock.now(), deps.rulesState().rules);
|
|
51
|
+
|
|
52
|
+
return Math.max(0, ...(detail?.item.levers.map((lever) => lever.seq) ?? []));
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { serverConfigSchema } from './config/server-config-schema.ts';
|
|
2
|
+
export type { ServerConfig } from './config/server-config-schema.ts';
|
|
3
|
+
export { noCallerProof } from './no-caller-proof.ts';
|
|
4
|
+
export { loadServerRules } from './rules/load-server-rules.ts';
|
|
5
|
+
export type { RulesCheckout } from './rules/load-server-rules.ts';
|
|
6
|
+
export { readRulesCheckout } from './rules/read-rules-checkout.ts';
|
|
7
|
+
export type { CheckoutAnswer } from './rules/read-rules-checkout.ts';
|
|
8
|
+
export { serveApi } from './serve-api.ts';
|
|
9
|
+
export type { ApiListeners } from './serve-api.ts';
|
|
10
|
+
export { startServer } from './start-server.ts';
|
|
11
|
+
export type { RunningServer, StartAnswer, StartServerInput } from './start-server.ts';
|
|
12
|
+
export type { ServerDeps, ServerLog } from './types.ts';
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RunningServer } from './start-server.ts';
|
|
2
|
+
import { startServer } from './start-server.ts';
|
|
3
|
+
|
|
4
|
+
// The server's entry: `bun apps/server/src/main.ts`. It exits 1 when the server cannot start.
|
|
5
|
+
const answer = await startServer({
|
|
6
|
+
env: process.env,
|
|
7
|
+
log: {
|
|
8
|
+
warn: (message, fields) => {
|
|
9
|
+
console.warn(JSON.stringify({ level: 'warn', message, ...fields }));
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (answer.kind === 'refused') {
|
|
15
|
+
console.error(answer.message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const server = answer.server;
|
|
20
|
+
|
|
21
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
22
|
+
process.once(signal, () => {
|
|
23
|
+
void stopAndExit(server);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function stopAndExit(running: RunningServer): Promise<void> {
|
|
28
|
+
await running.stop();
|
|
29
|
+
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CallerProof } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// The server's proof when its configuration names no trusted issuer: it proves no request, so
|
|
4
|
+
// every session and hook call is refused.
|
|
5
|
+
export const noCallerProof: CallerProof = {
|
|
6
|
+
prove: () => Promise.resolve(null),
|
|
7
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { buildInitialSenderState, buildOutboxStatus } from '@boxxy/dispatch';
|
|
2
|
+
import type { ClaimStore, Clock } from '@boxxy/model';
|
|
3
|
+
import type { OutboxControl } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
// The outbox of a server with no pepys section: the facts wait in the store, the status says
|
|
6
|
+
// `off`, and a nudge has no sender to wake.
|
|
7
|
+
export function createOffOutbox(store: ClaimStore, clock: Clock): OutboxControl {
|
|
8
|
+
return {
|
|
9
|
+
status: async () =>
|
|
10
|
+
buildOutboxStatus({
|
|
11
|
+
sink: 'none',
|
|
12
|
+
state: buildInitialSenderState(),
|
|
13
|
+
counts: await store.read((tx) => tx.readOutboxCounts()),
|
|
14
|
+
now: clock.now(),
|
|
15
|
+
isSending: false,
|
|
16
|
+
}),
|
|
17
|
+
nudge: () => {
|
|
18
|
+
// no sender runs, so the facts wait in the store
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { buildInitialSenderState, buildOutboxStatus, runSenderOnce } from '@boxxy/dispatch';
|
|
3
|
+
import type { SenderDeps, SenderState, SenderTurn } from '@boxxy/dispatch';
|
|
4
|
+
import { match } from 'ts-pattern';
|
|
5
|
+
import type { OutboxControl, ServerLog } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
export interface OutboxSenderInput extends SenderDeps {
|
|
8
|
+
// the wait after a turn that found nothing to send
|
|
9
|
+
readonly idleMs: number;
|
|
10
|
+
readonly log: ServerLog;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OutboxSender extends OutboxControl {
|
|
14
|
+
// ends the loop after the turn in flight
|
|
15
|
+
readonly stop: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The timer around the sender's turns. It waits as the last turn says, and a nudge, as after
|
|
19
|
+
// `outbox.retry`, ends the wait early. A turn that throws is logged, and the loop waits `idleMs`.
|
|
20
|
+
export function startOutboxSender(input: OutboxSenderInput): OutboxSender {
|
|
21
|
+
let state: SenderState = buildInitialSenderState();
|
|
22
|
+
let isSending = false;
|
|
23
|
+
|
|
24
|
+
// set by `stop`, which runs outside the loop
|
|
25
|
+
const run = { isStopped: false };
|
|
26
|
+
|
|
27
|
+
let wake = new AbortController();
|
|
28
|
+
|
|
29
|
+
const runLoop = async (): Promise<void> => {
|
|
30
|
+
while (!run.isStopped) {
|
|
31
|
+
const waitMs = await runTurn();
|
|
32
|
+
|
|
33
|
+
if (waitMs > 0 && !run.isStopped) {
|
|
34
|
+
await sleep(waitMs, null, { signal: wake.signal }).catch(() => null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
wake = wake.signal.aborted ? new AbortController() : wake;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const runTurn = async (): Promise<number> => {
|
|
42
|
+
isSending = true;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const turn = await runSenderOnce(input, state);
|
|
46
|
+
|
|
47
|
+
state = turn.state;
|
|
48
|
+
|
|
49
|
+
return pickWaitMs(turn, input.clock.now(), input.idleMs);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
input.log.warn('the outbox sender turn failed', { error: String(error) });
|
|
52
|
+
|
|
53
|
+
return input.idleMs;
|
|
54
|
+
} finally {
|
|
55
|
+
isSending = false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const loop = runLoop();
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
status: async () =>
|
|
63
|
+
buildOutboxStatus({
|
|
64
|
+
sink: 'pepys',
|
|
65
|
+
state,
|
|
66
|
+
counts: await input.store.read((tx) => tx.readOutboxCounts()),
|
|
67
|
+
now: input.clock.now(),
|
|
68
|
+
isSending,
|
|
69
|
+
}),
|
|
70
|
+
nudge: () => {
|
|
71
|
+
wake.abort();
|
|
72
|
+
},
|
|
73
|
+
stop: async () => {
|
|
74
|
+
run.isStopped = true;
|
|
75
|
+
|
|
76
|
+
wake.abort();
|
|
77
|
+
|
|
78
|
+
await loop;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickWaitMs(turn: SenderTurn, now: number, idleMs: number): number {
|
|
84
|
+
return match(turn.step)
|
|
85
|
+
.with('stored', 'split', 'parked', () => 0)
|
|
86
|
+
.with('idle', () => idleMs)
|
|
87
|
+
.with('retry', 'blocked', () => Math.max(0, (turn.state.nextTryAt ?? now) - now))
|
|
88
|
+
.exhaustive();
|
|
89
|
+
}
|