@dopamint-fun/open-sdk 0.2.0-dev.0 → 0.2.0-dev.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.
package/dist/cli.js CHANGED
@@ -760,6 +760,11 @@ async function commandTurn(args) {
760
760
  agent,
761
761
  agentId: fromHex(agentId),
762
762
  waitMs: values.wait === undefined ? 0 : Number(values.wait) * 1000,
763
+ /* Written before each acknowledgement, not once at the end. `turn`
764
+ acknowledges the viewless join and the named prepared/open notices as
765
+ it takes them, and an acknowledgement the file does not record is a
766
+ phase the next process cannot know it is in. */
767
+ checkpoint: (next) => saveSeatState(statePath, next),
763
768
  });
764
769
  saveSeatState(statePath, outcome.state);
765
770
  if (outcome.kind === "your-turn") {
@@ -1255,6 +1260,12 @@ async function commandRoom(args) {
1255
1260
  const room = await openRoom(client, seats);
1256
1261
  console.log(`table ${room.tableId}`);
1257
1262
  console.log(`seats ${room.seatCount}`);
1263
+ /* How full it is and what fills it. A room seats nobody but the agents
1264
+ invited to it: the chairs nobody takes stay empty, so an opener has to
1265
+ know how many are still open to know who to send the invite to. */
1266
+ console.log(`seated 1 of ${room.seatCount}`);
1267
+ console.log("fill the room deals when its last seat is taken, or two minutes after " +
1268
+ "its second agent sits, whichever comes first; untaken seats stay empty");
1258
1269
  console.log(`mode ${room.mode}`);
1259
1270
  console.log(`settlement ${room.settlement}`);
1260
1271
  if (room.executionId)
@@ -1263,7 +1274,7 @@ async function commandRoom(args) {
1263
1274
  operator forwards the whole thing rather than the table id alone: an id
1264
1275
  without the document is how a guest ends up asking the table routes for
1265
1276
  a decision. */
1266
- console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId)}`);
1277
+ console.log(`invite ${roomInvitePrompt(client.productUrl, room.tableId, room.seatCount - 1)}`);
1267
1278
  /* The room's own page, which exists before any hand does. Not `watch`:
1268
1279
  that word is the match link on every door, and `room join` prints it
1269
1280
  once the room composes, so two different links under one label was a
@@ -1276,10 +1287,23 @@ async function commandRoom(args) {
1276
1287
  fail("--table-id is required: the id the room's opener sent you");
1277
1288
  const joined = await joinRoomWhenComposed(client, tableId, {
1278
1289
  timeoutMs: Number(values["timeout-ms"]),
1279
- onWaiting: () => console.log(`waiting_for_guest table ${tableId} holds this agent's seat; the room composes when its guest joins`),
1290
+ onWaiting: (found) => {
1291
+ /* What the room is waiting for, said in its own numbers where the join
1292
+ answered with them. An opener's own join is refused rather than
1293
+ answered, so it has none to print. */
1294
+ const seats = found?.seated === undefined || found.seatCount === undefined
1295
+ ? ""
1296
+ : ` ${found.seated} of ${found.seatCount} seated;`;
1297
+ const deals = found?.fillAtMs === undefined
1298
+ ? " it deals when a second agent sits"
1299
+ : ` it deals by ${new Date(found.fillAtMs).toISOString()} or sooner if its last seat is taken`;
1300
+ console.log(`waiting_for_room table ${tableId} holds this agent's seat;${seats}${deals}`);
1301
+ },
1280
1302
  });
1281
1303
  console.log(`table ${joined.tableId}`);
1282
1304
  console.log(`joined ${joined.joined}`);
1305
+ if (joined.seated !== undefined && joined.seatCount !== undefined)
1306
+ console.log(`seated ${joined.seated} of ${joined.seatCount}`);
1283
1307
  if (joined.offerId === undefined) {
1284
1308
  /* A mock stack composes no offer, so there is no seat to accept and
1285
1309
  nothing for `--play` to drive. Said plainly rather than by an empty
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { DEFAULT_KEY_FILE, generateKeypair, keypairFromSeed, loadKeypair, saveKe
2
2
  export { deriveAgentId, registerCanonicalPayload, registerPayloadDigest, type RegisterAgentPayload, } from "./registration.js";
3
3
  export { canonicalIdentityPayload, nameAgent, parseIdentityFields, type AgentIdentityFields, type NameAgentArgs, } from "./identity.js";
4
4
  export { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, resumeSigningBytes, SESSION_VERSION, UnsupportedSessionVersionError, type ActionProposal, type ArtifactReference, type JoinRequest, type ResumeRequest, type SessionContext, } from "./sessionWire.js";
5
- export { encodeAckFrame, SESSION_ERROR_NAMES, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
5
+ export { encodeAckFrame, SESSION_ERROR_NAMES, MAX_PREDICTION_PACING_MS, admitAuthorityEvent, applyPredictionGateStatus, decodeAuthorityMessage, freshSessionBoundary, predictionGateMessage, predictionGatePreparationOf, predictionGateTargetReceipt, sessionErrorHint, sessionErrorName, PredictionGateConflictError, SessionBoundaryError, type AcceptedSessionBoundary, type AuthorityMessage, type PredictionGateMessage, type PredictionGateOpening, type PredictionGatePreparation, type PredictionGateRelease, type PredictionGateStatus, type PredictionWindowRef, type ReceiptRef, type ResumeCursor, type StateRef, type ViewSnapshot, } from "./sessionCodec.js";
6
6
  export { type OpenTableView, type PlayReport, type SeatDecision, type SeatDecisionResult, type SeatPosition, SessionClient, SessionRefusal, chooseSeatAction, playSeat, } from "./session.js";
7
7
  export { cardFromByte, decodeLegalActions, decodeParticipantView, encodeAction, pickAction, type Card, type Rank, type Suit, type TexasAction, type TexasLegalActions, type TexasSeatView, } from "./texas.js";
8
8
  export { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, recomputeSettlementDigest, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
@@ -14,8 +14,8 @@ export { joinRoom, MAX_ROOM_SEATS, MIN_ROOM_SEATS, openRoom, roomInvitePrompt, t
14
14
  export { AGENT_CLAIM_INVITE_DOMAIN, AGENT_CLAIM_INVITE_MAX_WINDOW_MS, AGENT_CLAIM_INVITE_TOKEN_BYTES, claimInviteCanonicalPayload, claimInviteLink, claimInviteSigningBytes, decodeClaimInvite, encodeClaimInvite, encodeClaimInviteCompact, mintClaimInvite, type ClaimInvite, } from "./claim.js";
15
15
  export { baselineDecision, baselineMove, opponentsStillIn, } from "./decide.js";
16
16
  export { handEquity, requiredEquity, cardCode, type HandEquity, type HandEquityOptions, } from "./equity.js";
17
- export { bestFive, cardIndex, handCategory, scoreHand, HAND_CATEGORIES, RANK_ORDER, SUIT_ORDER, type BestFive, type HandCategory, } from "./handRank.js";
17
+ export { cardIndex, handCategory, scoreHand, HAND_CATEGORIES, RANK_ORDER, SUIT_ORDER, type BestFive, type HandCategory, } from "./handRank.js";
18
18
  export { DEFAULT_SEAT_STATE_FILE, loadSeatState, saveSeatState, type SeatSessionState, } from "./seatState.js";
19
- export { newSeatState, openTurn, submitTurn, type SeatTurnOutcome, type SeatTurnPosition, } from "./seatTurn.js";
19
+ export { newSeatState, openTurn, submitTurn, type SeatStateCheckpoint, type SeatTurnOutcome, type SeatTurnPosition, } from "./seatTurn.js";
20
20
  export { chipAddressOf, joinTransaction, leaveTransaction, listTournaments, planJoin, presentToTournament, readAgentEntry, readPass, readOwner, playsHeldBy, giveBackTransaction, readTournament, sponsorAndExecute, tournamentIdArg, tournamentPossessionSignature, JoinRefused, TournamentRefusal, TOURNAMENT_POSSESSION_SEAT, type JoinPlan, type SponsoredExecution, type TournamentAgentEntry, type TournamentPass, type TournamentPlay, type TournamentOwnerView, type TournamentOwnerAgent, type TournamentChainObjects, type TournamentClient, type TournamentOverview, type TournamentQueueEntry, } from "./openTournament.js";
21
21
  export { ClientRefusal, describeNext, refusalLines, refusalMessage, refusalMessageFromText, type RefusalBody, type RefusalNext, } from "./refusal.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { DEFAULT_KEY_FILE, generateKeypair, keypairFromSeed, loadKeypair, saveKe
9
9
  export { deriveAgentId, registerCanonicalPayload, registerPayloadDigest, } from "./registration.js";
10
10
  export { canonicalIdentityPayload, nameAgent, parseIdentityFields, } from "./identity.js";
11
11
  export { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, resumeSigningBytes, SESSION_VERSION, UnsupportedSessionVersionError, } from "./sessionWire.js";
12
- export { encodeAckFrame, SESSION_ERROR_NAMES, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
12
+ export { encodeAckFrame, SESSION_ERROR_NAMES, MAX_PREDICTION_PACING_MS, admitAuthorityEvent, applyPredictionGateStatus, decodeAuthorityMessage, freshSessionBoundary, predictionGateMessage, predictionGatePreparationOf, predictionGateTargetReceipt, sessionErrorHint, sessionErrorName, PredictionGateConflictError, SessionBoundaryError, } from "./sessionCodec.js";
13
13
  export { SessionClient, SessionRefusal, chooseSeatAction, playSeat, } from "./session.js";
14
14
  export { cardFromByte, decodeLegalActions, decodeParticipantView, encodeAction, pickAction, } from "./texas.js";
15
15
  export { authorityOriginFromSessionBase, buildConsentRequest, digestForPrompt, recomputeSettlementDigest, settlementConsentPath, verifyConsentDisclosure, } from "./settlement.js";
@@ -26,7 +26,7 @@ export { joinRoom, MAX_ROOM_SEATS, MIN_ROOM_SEATS, openRoom, roomInvitePrompt, }
26
26
  export { AGENT_CLAIM_INVITE_DOMAIN, AGENT_CLAIM_INVITE_MAX_WINDOW_MS, AGENT_CLAIM_INVITE_TOKEN_BYTES, claimInviteCanonicalPayload, claimInviteLink, claimInviteSigningBytes, decodeClaimInvite, encodeClaimInvite, encodeClaimInviteCompact, mintClaimInvite, } from "./claim.js";
27
27
  export { baselineDecision, baselineMove, opponentsStillIn, } from "./decide.js";
28
28
  export { handEquity, requiredEquity, cardCode, } from "./equity.js";
29
- export { bestFive, cardIndex, handCategory, scoreHand, HAND_CATEGORIES, RANK_ORDER, SUIT_ORDER, } from "./handRank.js";
29
+ export { cardIndex, handCategory, scoreHand, HAND_CATEGORIES, RANK_ORDER, SUIT_ORDER, } from "./handRank.js";
30
30
  export { DEFAULT_SEAT_STATE_FILE, loadSeatState, saveSeatState, } from "./seatState.js";
31
31
  export { newSeatState, openTurn, submitTurn, } from "./seatTurn.js";
32
32
  export { chipAddressOf, joinTransaction, leaveTransaction, listTournaments, planJoin, presentToTournament, readAgentEntry, readPass, readOwner, playsHeldBy, giveBackTransaction, readTournament, sponsorAndExecute, tournamentIdArg, tournamentPossessionSignature, JoinRefused, TournamentRefusal, TOURNAMENT_POSSESSION_SEAT, } from "./openTournament.js";
package/dist/room.d.ts CHANGED
@@ -8,20 +8,35 @@ export interface RoomClient {
8
8
  /** A room as the product answers it on create. */
9
9
  export interface RoomOpened {
10
10
  tableId: string;
11
+ /** The chairs the room was opened for. An upper bound, not a roster: what
12
+ * nobody takes stays empty for the sitting. */
11
13
  seatCount: number;
12
14
  settlement: string;
13
15
  mode: string;
14
16
  /** Present once the authority has an execution behind the table. */
15
17
  executionId?: string;
16
18
  }
17
- /** A join. `offerId` and `seat` are present on an authority stack, which is
18
- * where the guest's seat is taken by accepting an offer with its own key. On
19
- * a mock stack the room is joined and there is nothing to accept. */
19
+ /** A join. `offerId` and `seat` are present once the room has composed, which
20
+ * is where the guest's seat is taken by accepting an offer with its own key.
21
+ * A room still filling answers how full it is instead, and when it deals to
22
+ * the agents who are already in it. On a mock stack there is never an offer
23
+ * and nothing to accept. */
20
24
  export interface RoomJoined {
21
25
  tableId: string;
22
26
  joined: boolean;
23
27
  offerId?: string;
24
28
  seat?: number;
29
+ /** How many chairs are taken, of `seatCount`. Absent once composed — every
30
+ * seat in the offer holds an agent. */
31
+ seated?: number;
32
+ seatCount?: number;
33
+ /** When the room deals to the agents who are here rather than waiting for
34
+ * the rest. Absent while one agent sits alone: a room of one has no sitting
35
+ * to start, and waits indefinitely. */
36
+ fillAtMs?: number;
37
+ /** `authority` or `mock`, so a caller can tell "no offer because this stack
38
+ * composes none" from "no offer yet". */
39
+ mode?: string;
25
40
  }
26
41
  /** The seat counts a private room can be opened with, both ends included.
27
42
  *
@@ -30,7 +45,12 @@ export interface RoomJoined {
30
45
  * before the agent tells anybody a room exists. */
31
46
  export declare const MIN_ROOM_SEATS = 2;
32
47
  export declare const MAX_ROOM_SEATS = 10;
33
- /** Open a room this agent will host. Seats above the two taken are house. */
48
+ /** Open a room this agent will host.
49
+ *
50
+ * `seatCount` is how many agents may come, including this one. No seat is
51
+ * ever filled for you: the room deals when its last chair is taken, or when
52
+ * its fill window closes with at least two agents in it, and the chairs
53
+ * nobody took stay empty for the sitting. */
34
54
  export declare function openRoom(client: RoomClient, seatCount: number): Promise<RoomOpened>;
35
55
  /** A join the product turned down, with the code it named. */
36
56
  export declare class RoomJoinRefusal extends Error {
@@ -53,8 +73,10 @@ export declare function joinRoom(client: RoomClient, tableId: string): Promise<R
53
73
  export declare function joinRoomWhenComposed(client: RoomClient, tableId: string, options?: {
54
74
  pollMs?: number;
55
75
  timeoutMs?: number;
56
- /** Called once, the first time the room is found still waiting. */
57
- onWaiting?: () => void;
76
+ /** Called once, the first time the room is found still waiting. Carries
77
+ * the join that found it waiting, where there was one: the opener's own
78
+ * join is refused rather than answered, so it has nothing to carry. */
79
+ onWaiting?: (joined?: RoomJoined) => void;
58
80
  sleep?: (ms: number) => Promise<void>;
59
81
  now?: () => number;
60
82
  }): Promise<RoomJoined>;
@@ -63,4 +85,4 @@ export declare function joinRoomWhenComposed(client: RoomClient, tableId: string
63
85
  * A prompt rather than a URL: what the guest needs is the document that says
64
86
  * what to do and the id to do it with, and an operator pasting two things in
65
87
  * the right order is how a room went unjoined. */
66
- export declare function roomInvitePrompt(productUrl: string, tableId: string): string;
88
+ export declare function roomInvitePrompt(productUrl: string, tableId: string, seatsOpen?: number): string;
package/dist/room.js CHANGED
@@ -46,7 +46,12 @@ async function signedFetch(client, method, target, body) {
46
46
  * before the agent tells anybody a room exists. */
47
47
  export const MIN_ROOM_SEATS = 2;
48
48
  export const MAX_ROOM_SEATS = 10;
49
- /** Open a room this agent will host. Seats above the two taken are house. */
49
+ /** Open a room this agent will host.
50
+ *
51
+ * `seatCount` is how many agents may come, including this one. No seat is
52
+ * ever filled for you: the room deals when its last chair is taken, or when
53
+ * its fill window closes with at least two agents in it, and the chairs
54
+ * nobody took stay empty for the sitting. */
50
55
  export async function openRoom(client, seatCount) {
51
56
  if (!Number.isInteger(seatCount) ||
52
57
  seatCount < MIN_ROOM_SEATS ||
@@ -105,6 +110,10 @@ export async function joinRoom(client, tableId) {
105
110
  joined: json.joined === true,
106
111
  offerId: json.offerId ?? undefined,
107
112
  seat: json.seat ?? undefined,
113
+ seated: json.seated ?? undefined,
114
+ seatCount: json.seatCount ?? undefined,
115
+ fillAtMs: json.fillAtMs ?? undefined,
116
+ mode: json.mode ?? undefined,
108
117
  };
109
118
  }
110
119
  /** Join, and wait for the room to fill when this agent already holds a seat
@@ -125,22 +134,37 @@ export async function joinRoomWhenComposed(client, tableId, options = {}) {
125
134
  const now = options.now ?? Date.now;
126
135
  const deadline = now() + timeoutMs;
127
136
  let told = false;
137
+ const stillWaiting = () => {
138
+ if (now() >= deadline)
139
+ throw new Error(`room ${tableId} had not composed after ${timeoutMs} ms; ` +
140
+ "send the invite line to the guests you are still waiting for, ask its " +
141
+ "owner to start the room with whoever is already in it, or open a room again later");
142
+ };
128
143
  for (;;) {
129
144
  try {
130
- return await joinRoom(client, tableId);
145
+ const joined = await joinRoom(client, tableId);
146
+ /* Joined, and the room is still filling: this agent holds a chair and
147
+ there is no offer to accept until the room deals. A mock stack
148
+ composes no offer at all, so its join is the answer rather than a step
149
+ towards one. */
150
+ if (joined.offerId !== undefined || joined.mode === "mock")
151
+ return joined;
152
+ stillWaiting();
153
+ if (!told) {
154
+ told = true;
155
+ options.onWaiting?.(joined);
156
+ }
131
157
  }
132
158
  catch (error) {
133
159
  if (!(error instanceof RoomJoinRefusal) || error.code !== "already_seated")
134
160
  throw error;
135
- if (now() >= deadline)
136
- throw new Error(`room ${tableId} still had nobody in its other seat after ${timeoutMs} ms; ` +
137
- "send the invite line to the guest, or open the room again later");
161
+ stillWaiting();
138
162
  if (!told) {
139
163
  told = true;
140
164
  options.onWaiting?.();
141
165
  }
142
- await sleep(pollMs);
143
166
  }
167
+ await sleep(pollMs);
144
168
  }
145
169
  }
146
170
  /** The one line an opener hands its guest, which is the whole invitation.
@@ -148,7 +172,13 @@ export async function joinRoomWhenComposed(client, tableId, options = {}) {
148
172
  * A prompt rather than a URL: what the guest needs is the document that says
149
173
  * what to do and the id to do it with, and an operator pasting two things in
150
174
  * the right order is how a room went unjoined. */
151
- export function roomInvitePrompt(productUrl, tableId) {
175
+ export function roomInvitePrompt(productUrl, tableId, seatsOpen) {
152
176
  const base = productUrl.replace(/\/$/, "");
153
- return `read ${base}/skills/private-room.md and join table ${tableId}`;
177
+ /* How many can still come, when the caller knows. A room deals to whoever
178
+ is in it when its window closes, so a guest reading this is being told
179
+ both what to do and how much room is left to do it in. */
180
+ const room = seatsOpen === undefined
181
+ ? ""
182
+ : ` (${seatsOpen} ${seatsOpen === 1 ? "seat" : "seats"} open)`;
183
+ return `read ${base}/skills/private-room.md and join table ${tableId}${room}`;
154
184
  }
@@ -1,5 +1,5 @@
1
1
  import type { SessionContext } from "./sessionWire.js";
2
- import type { ResumeCursor, ViewSnapshot } from "./sessionCodec.js";
2
+ import { type PredictionGateStatus, type ResumeCursor, type ViewSnapshot } from "./sessionCodec.js";
3
3
  /** Written beside the key file, as `.dopa-keypair` is. */
4
4
  export declare const DEFAULT_SEAT_STATE_FILE = ".dopa-seat";
5
5
  export interface SeatSessionState {
@@ -37,6 +37,8 @@ export interface SeatSessionState {
37
37
  * the documented `act` refused its first turn. Absent in a file written
38
38
  * before it existed. */
39
39
  agentId?: string | null;
40
+ predictionGate: SerialisedGate | null;
41
+ awaitingGatePrefix: boolean;
40
42
  }
41
43
  interface SerialisedContext {
42
44
  wireVersion: number;
@@ -70,22 +72,75 @@ interface SerialisedView {
70
72
  participantDeadlineMs: string;
71
73
  latestReceipt: SerialisedReceipt | null;
72
74
  }
75
+ interface SerialisedWindow {
76
+ windowId: string;
77
+ marketId: string;
78
+ contract: "pokerActionV1";
79
+ }
80
+ interface SerialisedPreparation {
81
+ window: SerialisedWindow;
82
+ actingSeat: number;
83
+ state: SerialisedState;
84
+ receipt: SerialisedReceipt | null;
85
+ originalDeadlineMs: string;
86
+ preparedAtMs: string;
87
+ }
88
+ interface SerialisedOpening {
89
+ preparation: SerialisedPreparation;
90
+ openedAtMs: string;
91
+ closesAtMs: string;
92
+ }
93
+ interface SerialisedRelease {
94
+ window: SerialisedWindow;
95
+ actingSeat: number;
96
+ state: SerialisedState;
97
+ receipt: SerialisedReceipt | null;
98
+ terminal: "locked" | "cancelled";
99
+ originalDeadlineMs: string;
100
+ arrivalMs: string;
101
+ lockedAtMs: string;
102
+ budgetMs: string;
103
+ }
104
+ type SerialisedGate = {
105
+ phase: "prepared";
106
+ preparation: SerialisedPreparation;
107
+ } | {
108
+ phase: "open";
109
+ opening: SerialisedOpening;
110
+ } | {
111
+ phase: "released";
112
+ release: SerialisedRelease;
113
+ };
73
114
  export declare function encodeContext(context: SessionContext): SerialisedContext;
74
115
  export declare function decodeContext(stored: SerialisedContext): SessionContext;
75
116
  export declare function encodeCursor(cursor: ResumeCursor): SerialisedCursor;
76
117
  export declare function decodeCursor(stored: SerialisedCursor): ResumeCursor;
77
118
  export declare function encodeView(view: ViewSnapshot): SerialisedView;
78
119
  export declare function decodeView(stored: SerialisedView): ViewSnapshot;
120
+ /** The accepted gate phase, in the file's own vocabulary. */
121
+ export declare function encodeGate(gate: PredictionGateStatus): SerialisedGate;
122
+ export declare function decodeGate(stored: SerialisedGate): PredictionGateStatus;
79
123
  /** Read the seat's state, or null where it has not been opened yet.
80
124
  *
81
125
  * A state file whose retained context names a session version this build
82
126
  * cannot speak is refused here, before any network call or signature: the
83
127
  * sitting it was written for ended under the previous contract, and its
84
128
  * cursor and token do not carry over. The file is left untouched, so an
85
- * operator can still read what the seat was doing. */
129
+ * operator can still read what the seat was doing.
130
+ *
131
+ * A file that does name this version and omits the gate boundary is refused
132
+ * too. Reading a missing `awaitingGatePrefix` as "not awaiting" would have a
133
+ * restarted seat answer the private view it acknowledged a viewless join
134
+ * for, which is the one thing the flag exists to prevent. */
86
135
  export declare function loadSeatState(path: string): SeatSessionState | null;
87
- /** Write it back, whole. Read-modify-write, never append: a half-written
88
- * cursor is a session that cannot resume, and the fix for that is a rejoin
89
- * that costs the seat every turn in between. */
136
+ /** Write it back, whole, and durably. Read-modify-write, never append: a
137
+ * half-written cursor is a session that cannot resume, and the fix for that
138
+ * is a rejoin that costs the seat every turn in between.
139
+ *
140
+ * Same-directory temporary file, fsync, rename, then fsync the directory -
141
+ * the repository's durable-private-file convention
142
+ * (`arena_authority::recovery_anchor::write_durable_private_file`). `turn`
143
+ * acknowledges an event only after this returns, so a torn file here would
144
+ * be a phase the authority believes was received and the seat cannot see. */
90
145
  export declare function saveSeatState(path: string, state: SeatSessionState): void;
91
146
  export {};
package/dist/seatState.js CHANGED
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, openSync, closeSync, fsyncSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import { fromHex, toHex0x } from "./bytes.js";
3
4
  import { requireSessionVersion } from "./sessionWire.js";
5
+ import { bindPredictionGateOpening, bindPredictionGatePreparation, bindPredictionGateRelease, } from "./sessionCodec.js";
4
6
  /* What a seat has to remember between one command and the next.
5
7
  *
6
8
  * `playSeat` is a loop: it joins, plays every turn and exits at a terminal, so
@@ -112,26 +114,173 @@ export function decodeView(stored) {
112
114
  latestReceipt: decodeReceipt(stored.latestReceipt),
113
115
  };
114
116
  }
117
+ const encodeWindow = (window) => ({
118
+ windowId: window.windowId.toString(),
119
+ marketId: window.marketId.toString(),
120
+ contract: window.contract,
121
+ });
122
+ function decodeWindow(stored) {
123
+ if (stored.contract !== "pokerActionV1")
124
+ throw new Error(`stored prediction window names contract ${stored.contract}`);
125
+ return {
126
+ windowId: BigInt(stored.windowId),
127
+ marketId: BigInt(stored.marketId),
128
+ contract: "pokerActionV1",
129
+ };
130
+ }
131
+ function encodePreparation(preparation) {
132
+ return {
133
+ window: encodeWindow(preparation.window),
134
+ actingSeat: preparation.actingSeat,
135
+ state: encodeState(preparation.state),
136
+ receipt: encodeReceipt(preparation.receipt),
137
+ originalDeadlineMs: preparation.originalDeadlineMs.toString(),
138
+ preparedAtMs: preparation.preparedAtMs.toString(),
139
+ };
140
+ }
141
+ function decodePreparation(stored) {
142
+ return bindPredictionGatePreparation({
143
+ window: decodeWindow(stored.window),
144
+ actingSeat: stored.actingSeat,
145
+ state: decodeState(stored.state),
146
+ receipt: decodeReceipt(stored.receipt),
147
+ originalDeadlineMs: BigInt(stored.originalDeadlineMs),
148
+ preparedAtMs: BigInt(stored.preparedAtMs),
149
+ });
150
+ }
151
+ function encodeOpening(opening) {
152
+ return {
153
+ preparation: encodePreparation(opening.preparation),
154
+ openedAtMs: opening.openedAtMs.toString(),
155
+ closesAtMs: opening.closesAtMs.toString(),
156
+ };
157
+ }
158
+ /** The accepted gate phase, in the file's own vocabulary. */
159
+ export function encodeGate(gate) {
160
+ switch (gate.phase) {
161
+ case "prepared":
162
+ return { phase: "prepared", preparation: encodePreparation(gate.preparation) };
163
+ case "open":
164
+ return { phase: "open", opening: encodeOpening(gate.opening) };
165
+ case "released":
166
+ return {
167
+ phase: "released",
168
+ release: {
169
+ window: encodeWindow(gate.release.window),
170
+ actingSeat: gate.release.actingSeat,
171
+ state: encodeState(gate.release.state),
172
+ receipt: encodeReceipt(gate.release.receipt),
173
+ terminal: gate.release.terminal,
174
+ originalDeadlineMs: gate.release.originalDeadlineMs.toString(),
175
+ arrivalMs: gate.release.arrivalMs.toString(),
176
+ lockedAtMs: gate.release.lockedAtMs.toString(),
177
+ budgetMs: gate.release.budgetMs.toString(),
178
+ },
179
+ };
180
+ }
181
+ }
182
+ export function decodeGate(stored) {
183
+ switch (stored.phase) {
184
+ case "prepared":
185
+ return {
186
+ phase: "prepared",
187
+ preparation: decodePreparation(stored.preparation),
188
+ };
189
+ case "open":
190
+ return {
191
+ phase: "open",
192
+ opening: bindPredictionGateOpening({
193
+ preparation: decodePreparation(stored.opening.preparation),
194
+ openedAtMs: BigInt(stored.opening.openedAtMs),
195
+ closesAtMs: BigInt(stored.opening.closesAtMs),
196
+ }),
197
+ };
198
+ case "released":
199
+ return {
200
+ phase: "released",
201
+ release: bindPredictionGateRelease({
202
+ window: decodeWindow(stored.release.window),
203
+ actingSeat: stored.release.actingSeat,
204
+ state: decodeState(stored.release.state),
205
+ receipt: decodeReceipt(stored.release.receipt),
206
+ terminal: stored.release.terminal,
207
+ originalDeadlineMs: BigInt(stored.release.originalDeadlineMs),
208
+ arrivalMs: BigInt(stored.release.arrivalMs),
209
+ lockedAtMs: BigInt(stored.release.lockedAtMs),
210
+ budgetMs: BigInt(stored.release.budgetMs),
211
+ }),
212
+ };
213
+ default:
214
+ throw new Error("stored prediction gate names no phase");
215
+ }
216
+ }
115
217
  /** Read the seat's state, or null where it has not been opened yet.
116
218
  *
117
219
  * A state file whose retained context names a session version this build
118
220
  * cannot speak is refused here, before any network call or signature: the
119
221
  * sitting it was written for ended under the previous contract, and its
120
222
  * cursor and token do not carry over. The file is left untouched, so an
121
- * operator can still read what the seat was doing. */
223
+ * operator can still read what the seat was doing.
224
+ *
225
+ * A file that does name this version and omits the gate boundary is refused
226
+ * too. Reading a missing `awaitingGatePrefix` as "not awaiting" would have a
227
+ * restarted seat answer the private view it acknowledged a viewless join
228
+ * for, which is the one thing the flag exists to prevent. */
122
229
  export function loadSeatState(path) {
123
230
  if (!existsSync(path))
124
231
  return null;
125
232
  const parsed = JSON.parse(readFileSync(path, "utf8"));
126
233
  if (!parsed.offerId || typeof parsed.seat !== "number")
127
234
  throw new Error(`${path} is not a seat state file`);
128
- if (parsed.context)
235
+ if (parsed.context) {
129
236
  requireSessionVersion(parsed.context.sessionVersion);
237
+ if (typeof parsed.awaitingGatePrefix !== "boolean" ||
238
+ parsed.predictionGate === undefined)
239
+ throw new Error(`${path} retains a session but no prediction-gate boundary; it cannot be resumed safely`);
240
+ if (parsed.predictionGate !== null)
241
+ decodeGate(parsed.predictionGate);
242
+ }
130
243
  return parsed;
131
244
  }
132
- /** Write it back, whole. Read-modify-write, never append: a half-written
133
- * cursor is a session that cannot resume, and the fix for that is a rejoin
134
- * that costs the seat every turn in between. */
245
+ /** Distinguishes concurrent writers' temporary files, as the Rust convention
246
+ * does with its process-scoped sequence. */
247
+ let nextTemporary = 0;
248
+ /** Write it back, whole, and durably. Read-modify-write, never append: a
249
+ * half-written cursor is a session that cannot resume, and the fix for that
250
+ * is a rejoin that costs the seat every turn in between.
251
+ *
252
+ * Same-directory temporary file, fsync, rename, then fsync the directory -
253
+ * the repository's durable-private-file convention
254
+ * (`arena_authority::recovery_anchor::write_durable_private_file`). `turn`
255
+ * acknowledges an event only after this returns, so a torn file here would
256
+ * be a phase the authority believes was received and the seat cannot see. */
135
257
  export function saveSeatState(path, state) {
136
- writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
258
+ const directory = dirname(path) || ".";
259
+ mkdirSync(directory, { recursive: true });
260
+ const temporary = `${path}.tmp-${process.pid}-${nextTemporary++}`;
261
+ try {
262
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, {
263
+ mode: 0o600,
264
+ flag: "wx",
265
+ });
266
+ const file = openSync(temporary, "r+");
267
+ try {
268
+ fsyncSync(file);
269
+ }
270
+ finally {
271
+ closeSync(file);
272
+ }
273
+ renameSync(temporary, path);
274
+ }
275
+ catch (error) {
276
+ rmSync(temporary, { force: true });
277
+ throw error;
278
+ }
279
+ const parent = openSync(directory, "r");
280
+ try {
281
+ fsyncSync(parent);
282
+ }
283
+ finally {
284
+ closeSync(parent);
285
+ }
137
286
  }
@@ -1,8 +1,7 @@
1
1
  import type { AgentKeypair } from "./keypair.js";
2
2
  import type { TexasAction, TexasLegalActions } from "./texas.js";
3
- import type { AuthorityMessage } from "./sessionCodec.js";
3
+ import { type AuthorityMessage, type ResumeCursor } from "./sessionCodec.js";
4
4
  import type { SessionContext } from "./sessionWire.js";
5
- import type { ResumeCursor } from "./sessionCodec.js";
6
5
  import { type OpenTableSeatView, type OpenTableTalkLine, type OpenTableView, type SeatSession, type OpenSittingStatus } from "./session.js";
7
6
  import { type SeatSessionState } from "./seatState.js";
8
7
  export interface SeatTurnArgs {
@@ -11,6 +10,19 @@ export interface SeatTurnArgs {
11
10
  agentId: Uint8Array;
12
11
  fetchImpl?: typeof fetch;
13
12
  }
13
+ /** Persist the accepted boundary before it is acknowledged.
14
+ *
15
+ * `openTurn` acknowledges events, and an acknowledgement is a promise that
16
+ * this seat has the event: the authority may drop it from the replay window
17
+ * on the strength of it. Two commands are two processes, so the promise has
18
+ * to be on disk before it is made -- otherwise a restart lands on a seat that
19
+ * acknowledged a viewless join or a prepared notice and has no record of
20
+ * either, which is exactly the seat that would answer a view it must refuse.
21
+ *
22
+ * Supplied by the CLI from `saveSeatState`. A rejection means no
23
+ * acknowledgement and no decision: the previously persisted boundary stays
24
+ * valid, and the authority will replay from it. */
25
+ export type SeatStateCheckpoint = (state: SeatSessionState) => void | Promise<void>;
14
26
  /** What this seat is looking at, in the shape a reader can act on. */
15
27
  export interface SeatTurnPosition {
16
28
  seat: number;
@@ -121,6 +133,7 @@ export declare function attachFailureOutcome(error: unknown, sitting: OpenSittin
121
133
  * an answer, not a failure. */
122
134
  export declare function openTurn(args: SeatTurnArgs & {
123
135
  waitMs?: number;
136
+ checkpoint: SeatStateCheckpoint;
124
137
  }): Promise<SeatTurnOutcome>;
125
138
  export interface SubmitTurnResult {
126
139
  committed: boolean;