@dopamint-fun/open-sdk 0.2.0-dev.0 → 0.2.0-dev.10

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/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,179 @@ 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
+ terminalDigest: gate.release.terminalDigest
179
+ ? toHex0x(gate.release.terminalDigest)
180
+ : null,
181
+ },
182
+ };
183
+ }
184
+ }
185
+ export function decodeGate(stored) {
186
+ switch (stored.phase) {
187
+ case "prepared":
188
+ return {
189
+ phase: "prepared",
190
+ preparation: decodePreparation(stored.preparation),
191
+ };
192
+ case "open":
193
+ return {
194
+ phase: "open",
195
+ opening: bindPredictionGateOpening({
196
+ preparation: decodePreparation(stored.opening.preparation),
197
+ openedAtMs: BigInt(stored.opening.openedAtMs),
198
+ closesAtMs: BigInt(stored.opening.closesAtMs),
199
+ }),
200
+ };
201
+ case "released":
202
+ return {
203
+ phase: "released",
204
+ release: bindPredictionGateRelease({
205
+ window: decodeWindow(stored.release.window),
206
+ actingSeat: stored.release.actingSeat,
207
+ state: decodeState(stored.release.state),
208
+ receipt: decodeReceipt(stored.release.receipt),
209
+ terminal: stored.release.terminal,
210
+ originalDeadlineMs: BigInt(stored.release.originalDeadlineMs),
211
+ arrivalMs: BigInt(stored.release.arrivalMs),
212
+ lockedAtMs: BigInt(stored.release.lockedAtMs),
213
+ budgetMs: BigInt(stored.release.budgetMs),
214
+ terminalDigest: stored.release.terminalDigest
215
+ ? fromHex(stored.release.terminalDigest)
216
+ : null,
217
+ }),
218
+ };
219
+ default:
220
+ throw new Error("stored prediction gate names no phase");
221
+ }
222
+ }
115
223
  /** Read the seat's state, or null where it has not been opened yet.
116
224
  *
117
225
  * A state file whose retained context names a session version this build
118
226
  * cannot speak is refused here, before any network call or signature: the
119
227
  * sitting it was written for ended under the previous contract, and its
120
228
  * cursor and token do not carry over. The file is left untouched, so an
121
- * operator can still read what the seat was doing. */
229
+ * operator can still read what the seat was doing.
230
+ *
231
+ * A file that does name this version and omits the gate boundary is refused
232
+ * too. Reading a missing `awaitingGatePrefix` as "not awaiting" would have a
233
+ * restarted seat answer the private view it acknowledged a viewless join
234
+ * for, which is the one thing the flag exists to prevent. */
122
235
  export function loadSeatState(path) {
123
236
  if (!existsSync(path))
124
237
  return null;
125
238
  const parsed = JSON.parse(readFileSync(path, "utf8"));
126
239
  if (!parsed.offerId || typeof parsed.seat !== "number")
127
240
  throw new Error(`${path} is not a seat state file`);
128
- if (parsed.context)
241
+ if (parsed.context) {
129
242
  requireSessionVersion(parsed.context.sessionVersion);
243
+ if (typeof parsed.awaitingGatePrefix !== "boolean" ||
244
+ parsed.predictionGate === undefined)
245
+ throw new Error(`${path} retains a session but no prediction-gate boundary; it cannot be resumed safely`);
246
+ if (parsed.predictionGate !== null)
247
+ decodeGate(parsed.predictionGate);
248
+ }
130
249
  return parsed;
131
250
  }
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. */
251
+ /** Distinguishes concurrent writers' temporary files, as the Rust convention
252
+ * does with its process-scoped sequence. */
253
+ let nextTemporary = 0;
254
+ /** Write it back, whole, and durably. Read-modify-write, never append: a
255
+ * half-written cursor is a session that cannot resume, and the fix for that
256
+ * is a rejoin that costs the seat every turn in between.
257
+ *
258
+ * Same-directory temporary file, fsync, rename, then fsync the directory -
259
+ * the repository's durable-private-file convention
260
+ * (`arena_authority::recovery_anchor::write_durable_private_file`). `turn`
261
+ * acknowledges an event only after this returns, so a torn file here would
262
+ * be a phase the authority believes was received and the seat cannot see. */
135
263
  export function saveSeatState(path, state) {
136
- writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
264
+ const directory = dirname(path) || ".";
265
+ mkdirSync(directory, { recursive: true });
266
+ const temporary = `${path}.tmp-${process.pid}-${nextTemporary++}`;
267
+ try {
268
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, {
269
+ mode: 0o600,
270
+ flag: "wx",
271
+ });
272
+ const file = openSync(temporary, "r+");
273
+ try {
274
+ fsyncSync(file);
275
+ }
276
+ finally {
277
+ closeSync(file);
278
+ }
279
+ renameSync(temporary, path);
280
+ }
281
+ catch (error) {
282
+ rmSync(temporary, { force: true });
283
+ throw error;
284
+ }
285
+ const parent = openSync(directory, "r");
286
+ try {
287
+ fsyncSync(parent);
288
+ }
289
+ finally {
290
+ closeSync(parent);
291
+ }
137
292
  }
@@ -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;
package/dist/seatTurn.js CHANGED
@@ -2,8 +2,9 @@ import { randomBytes } from "node:crypto";
2
2
  import { toHex0x } from "./bytes.js";
3
3
  import { acceptAndAwaitAdmission, readOffer } from "./offer.js";
4
4
  import { decodeLegalActions, decodeParticipantView, encodeAction, } from "./texas.js";
5
+ import { admitAuthorityEvent, equalReceiptRef, freshSessionBoundary, predictionGatePreparationOf, predictionGateTargetReceipt, } from "./sessionCodec.js";
5
6
  import { agentReadCapability, openSeatSession, readPublicTable, readTableTalk, turnIsStillOpen, SessionRefusal, afterRefusal, readSittingStatus, } from "./session.js";
6
- import { decodeContext, decodeCursor, decodeView, encodeContext, encodeCursor, encodeView, } from "./seatState.js";
7
+ import { decodeContext, decodeCursor, decodeGate, decodeView, encodeContext, encodeCursor, encodeGate, encodeView, } from "./seatState.js";
7
8
  /** Whether `view` is a turn this seat has already answered.
8
9
  *
9
10
  * `act` does not move the cursor, so the next `turn` resumes from before the
@@ -87,6 +88,12 @@ export async function attach(session, state, reopen) {
87
88
  clientNonce: toHex0x(nonce),
88
89
  context: joined.context ? encodeContext(joined.context) : null,
89
90
  token: opened.client.token ?? null,
91
+ /* A fresh admission resets the gate boundary with everything else:
92
+ the phase belonged to the session that just ended. */
93
+ cursor: { sequence: "0", witnessedReceipt: null },
94
+ view: null,
95
+ predictionGate: null,
96
+ awaitingGatePrefix: false,
90
97
  },
91
98
  context: joined.context ?? null,
92
99
  cursor: { sequence: 0n, witnessedReceipt: null },
@@ -124,13 +131,21 @@ const holeFromView = (view) => {
124
131
  return null;
125
132
  }
126
133
  };
127
- function persisted(session, state, context, cursor, view) {
134
+ /** The file as it stands after one accepted event: the session it belongs to,
135
+ * the boundary that was accepted, and the position to answer if there is one.
136
+ * The gate phase travels with the cursor because they are one promise - a
137
+ * cursor that outran its phase is a seat that forgot it was held. */
138
+ function persisted(session, state, context, cursor, view, boundary) {
128
139
  return {
129
140
  ...state,
130
141
  context: context ? encodeContext(context) : state.context,
131
142
  token: session.client.token ?? state.token,
132
143
  cursor: encodeCursor(cursor),
133
144
  view: view ? encodeView(view) : state.view,
145
+ predictionGate: boundary.predictionGate
146
+ ? encodeGate(boundary.predictionGate)
147
+ : null,
148
+ awaitingGatePrefix: boundary.awaitingGatePrefix,
134
149
  };
135
150
  }
136
151
  /** What `turn` answers when it could not open a session.
@@ -201,18 +216,66 @@ export async function openTurn(args) {
201
216
  session = attached.session;
202
217
  args = { ...args, state: attached.state };
203
218
  let { context, cursor, messages } = attached;
219
+ /* What this seat has already accepted, restored from the file: the sequence
220
+ and receipt floor its cursor names, the gate phase it holds, and whether a
221
+ viewless admission still owes its prepared prefix. The view is not
222
+ restored - a restart drops it exactly as
223
+ `ParticipantSessionState::restore` does, and the committed floor carries
224
+ what the boundary still needs. */
225
+ let boundary = {
226
+ ...freshSessionBoundary(),
227
+ context,
228
+ sequence: cursor.sequence,
229
+ receiptFloor: cursor.witnessedReceipt,
230
+ predictionGate: args.state.predictionGate
231
+ ? decodeGate(args.state.predictionGate)
232
+ : null,
233
+ awaitingGatePrefix: args.state.awaitingGatePrefix,
234
+ };
204
235
  const until = Date.now() + (args.waitMs ?? 0);
236
+ /* Accept, persist, acknowledge - in that order, for every event this door
237
+ takes. The acknowledgement tells the authority this seat holds the event,
238
+ and it may drop the event from the replay window on the strength of it; a
239
+ promise made before the file is on disk is one a restart cannot keep.
240
+
241
+ A refusal from `admitAuthorityEvent` throws out of here with nothing
242
+ written and nothing acknowledged, which is what leaves the previously
243
+ persisted boundary resumable. A re-delivery answers `null`: it was
244
+ persisted and acknowledged by the process that first applied it, and
245
+ writing its cursor again would walk this seat's boundary backwards. */
246
+ const accept = async (message, view) => {
247
+ const admitted = admitAuthorityEvent(boundary, message);
248
+ if (admitted.disposition !== "applied")
249
+ return null;
250
+ const originToken = session.client.token;
251
+ if (!originToken)
252
+ throw new Error("session token missing");
253
+ /* A fresh join is a new session: the view the previous one printed
254
+ describes a turn that no longer exists. */
255
+ const previous = message.type === "sessionJoined"
256
+ ? { ...args.state, view: null }
257
+ : args.state;
258
+ const state = persisted(session, previous, message.context, message.cursor, view, admitted.boundary);
259
+ await args.checkpoint(state);
260
+ boundary = admitted.boundary;
261
+ context = message.context;
262
+ cursor = message.cursor;
263
+ args = { ...args, state };
264
+ await session.client.acknowledge(message.context, message.cursor, originToken);
265
+ return state;
266
+ };
205
267
  for (;;) {
206
268
  for (const message of messages) {
207
269
  if (message.type === "error" && !message.retryable)
208
270
  throw new SessionRefusal(message);
209
- if ("context" in message)
210
- context = message.context;
271
+ /* Acknowledgements, pendings, errors and challenges carry no boundary. */
272
+ if (!("cursor" in message))
273
+ continue;
211
274
  if (message.type === "sessionTerminal") {
212
- const originToken = session.client.token;
213
- if (!originToken)
214
- throw new Error("session token missing");
215
- await session.client.acknowledge(message.context, message.cursor, originToken);
275
+ const state = await accept(message, null);
276
+ /* A re-delivered terminal is this file's own last event: a resume at
277
+ the terminal cursor repeats it, so there is nothing to write or
278
+ promise again - and the sitting is still over. */
216
279
  return {
217
280
  kind: "terminal",
218
281
  /* Carried out rather than dropped: `consent` takes exactly these two,
@@ -220,21 +283,21 @@ export async function openTurn(args) {
220
283
  two-command tour had no settle at all. */
221
284
  terminalNonce: message.finalState.nonce.toString(),
222
285
  terminalCommitment: toHex0x(message.finalState.commitment),
223
- state: persisted(session, args.state, context, message.cursor, null),
286
+ state: state ?? args.state,
224
287
  };
225
288
  }
226
- if (message.type === "predictionGateReleased") {
227
- const originToken = session.client.token;
228
- if (!originToken)
229
- throw new Error("session token missing");
230
- await session.client.acknowledge(message.context, message.cursor, originToken);
231
- cursor = message.cursor;
232
- continue;
233
- }
289
+ /* A named notice and a viewless admission are acknowledged and waited
290
+ on: no private view arrived, so there is nothing here to answer and
291
+ nothing to decide. The phase they carry is persisted first. */
234
292
  const view = "view" in message ? message.view : null;
235
- if (!view || !("cursor" in message))
293
+ const state = await accept(message, view);
294
+ /* A re-delivery is not a turn: the view it carries was answered or
295
+ waited out by the process that applied it, and nothing was written
296
+ here to hand back. */
297
+ if (!state)
298
+ continue;
299
+ if (!view)
236
300
  continue;
237
- cursor = message.cursor;
238
301
  /* A view with no legal actions is the table moving without this seat.
239
302
  One whose deadline has passed is this seat's turn already lost, and
240
303
  answering it would sign for a turn that is closed. And one at or before
@@ -275,11 +338,11 @@ export async function openTurn(args) {
275
338
  deadlineMs: view.participantDeadlineMs.toString(),
276
339
  msRemaining: Number(view.participantDeadlineMs) - Date.now(),
277
340
  },
278
- state: persisted(session, args.state, context, cursor, view),
341
+ state,
279
342
  };
280
343
  }
281
344
  if (Date.now() >= until) {
282
- const waiting = persisted(session, args.state, context, cursor, null);
345
+ const waiting = persisted(session, args.state, context, cursor, null, boundary);
283
346
  /* Asked only here, where the loop is about to say "waiting" anyway: a
284
347
  seat with no chips is not waiting for a turn, it is out, and a caller
285
348
  polling `waiting` at the default `--wait 0` would spin at full rate for
@@ -309,6 +372,18 @@ export async function submitTurn(args) {
309
372
  const view = decodeView(args.state.view);
310
373
  if (!turnIsStillOpen(view, Date.now()))
311
374
  throw new Error("the deadline for that turn has passed; run `turn` again for the next one");
375
+ /* A retained gate that still holds this seat's own turn means the authority
376
+ has not released it: answering the stored position would be inferring on
377
+ a turn nobody may act in yet. `turn` refuses to hand such a view out, so
378
+ this only fires on a stored position the gate later reclaimed. */
379
+ const retained = args.state.predictionGate
380
+ ? decodeGate(args.state.predictionGate)
381
+ : null;
382
+ if (retained &&
383
+ retained.phase !== "released" &&
384
+ equalReceiptRef(predictionGateTargetReceipt(retained), view.latestReceipt) &&
385
+ predictionGatePreparationOf(retained)?.actingSeat === args.state.seat)
386
+ throw new Error("this turn is held behind a prediction gate; run `turn` again once it releases");
312
387
  const openWith = (clientNonce) => openSeatSession({
313
388
  productUrl: args.state.productUrl,
314
389
  offerId: args.state.offerId,
@@ -403,7 +478,7 @@ async function sayAtTable(fetchImpl, session, agent, agentId, say) {
403
478
  if (!trimmed)
404
479
  return false;
405
480
  const { mintAgentHttpCapability, AGENT_HTTP_CAPABILITY_HEADER } = await import("./agentHttp.js");
406
- const target = `/open/v1/executions/${session.executionHex}/talk`;
481
+ const target = `/v1/executions/${session.executionHex}/talk`;
407
482
  const body = new TextEncoder().encode(JSON.stringify({ say: trimmed }));
408
483
  try {
409
484
  const { header } = await mintAgentHttpCapability(agent, agentId, {
@@ -438,5 +513,7 @@ export function newSeatState(productUrl, offerId, seat) {
438
513
  token: null,
439
514
  cursor: { sequence: "0", witnessedReceipt: null },
440
515
  view: null,
516
+ predictionGate: null,
517
+ awaitingGatePrefix: false,
441
518
  };
442
519
  }
package/dist/session.d.ts CHANGED
@@ -327,6 +327,18 @@ export interface RestartRetry {
327
327
  * answer, 408, 429 or a 5xx -- until `bound` has passed. Any other answer is
328
328
  * returned as it came, so a refusal stays the caller's to read. */
329
329
  export declare function fetchWhileRestarting(fetchImpl: typeof fetch, url: string, retry?: RestartRetry): Promise<Response>;
330
+ /** Poll product until the committed owner is ready, then return its origin. */
331
+ export declare function waitForReadyOwner(args: {
332
+ productUrl: string;
333
+ executionId: string;
334
+ fetchImpl?: typeof fetch;
335
+ timeoutMs?: number;
336
+ pauseMs?: number;
337
+ sleep?: (ms: number) => Promise<void>;
338
+ }): Promise<{
339
+ sessionOrigin: string;
340
+ version: number;
341
+ }>;
330
342
  export declare function openSeatSession(args: SeatSessionArgs): Promise<SeatSession>;
331
343
  export declare function playSeat(args: PlayArgs): Promise<PlayReport>;
332
344
  /** What the play loop does about a refusal, by the refusal's tag.