@astrosheep/square 0.3.41 → 0.3.43

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,10 +1,11 @@
1
1
  import { formatActivityId, parseActivityId } from './square-core.js';
2
2
  import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName } from './decisions.js';
3
- import { nameKey, SquareError } from './model.js';
3
+ import { isSquareError, nameKey, SquareError, validateName } from './model.js';
4
4
  import { participantIdentity } from './participant-identity.js';
5
5
  import { deliverPending } from './delivery-operations.js';
6
6
  import { decideCatch } from './catch-decisions.js';
7
- import { publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
7
+ import { claimSessionParticipant, claimSessionTakeover, readParticipantOwner, sessionOwnsParticipant, withOwnershipClaimLock } from './registry.js';
8
+ import { applyWakeRouteToState, defaultWakeRouteCapabilities, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, sessionCanEndParticipant, ROUTE_FRESH_MS } from './routes.js';
8
9
  function processIdentity(env) {
9
10
  const choices = [
10
11
  [env.CLAUDE_CODE_SESSION_ID, 'claude-code'], [env.CODEX_THREAD_ID, 'codex'],
@@ -13,33 +14,76 @@ function processIdentity(env) {
13
14
  const found = choices.find(([session]) => session?.trim());
14
15
  return found === undefined ? { session: `process:${process.pid}`, channel: 'unknown' } : { session: found[0].trim(), channel: found[1] };
15
16
  }
16
- async function publishIdentityRoute(context, participant) {
17
+ async function identityRouteDraft(context, participant) {
17
18
  if (context.location === undefined || context.location === 'memory')
18
- return;
19
+ return undefined;
19
20
  const identity = processIdentity(context.env ?? process.env);
20
- if (context.hostLedger === undefined)
21
+ const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
22
+ const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
23
+ return resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
24
+ }
25
+ async function currentOwnerEpoch(context, participant) {
26
+ if (context.location === undefined || context.location === 'memory')
27
+ return undefined;
28
+ const owner = await readParticipantOwner(context.location, participant, context.env ?? process.env);
29
+ return owner?.epoch;
30
+ }
31
+ async function publishIdentityRoute(context, participant, epoch) {
32
+ if (context.location === undefined || context.location === 'memory')
21
33
  return;
34
+ const identity = processIdentity(context.env ?? process.env);
22
35
  const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
23
- const capabilities = await defaultWakeRouteCapabilities(context.hostLedger);
24
- const route = await resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
36
+ const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
37
+ const route = resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
25
38
  if (route === undefined)
26
39
  return;
27
- await publishWakeRoute(context.artifact, route, { at: context.clock() }).catch(() => undefined);
40
+ const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
41
+ await publishWakeRoute(context.artifact, { ...route, ...(ownerEpoch === undefined ? {} : { epoch: ownerEpoch }) }, { at: context.clock(), requireCurrentSession: true }).catch(() => undefined);
28
42
  }
29
- async function retireIdentityRoute(context, participant) {
43
+ async function retireIdentityRoute(context, participant, expectedEpoch) {
30
44
  if (context.location === undefined || context.location === 'memory')
31
45
  return;
32
46
  const identity = processIdentity(context.env ?? process.env);
33
- await retireWakeRouteFromArtifact(context.artifact, { location: context.location, participant, sessionId: identity.session }).catch(() => undefined);
47
+ await retireWakeRouteFromArtifact(context.artifact, { location: context.location, participant, sessionId: identity.session }, expectedEpoch === undefined ? {} : { expectedEpoch }).catch(() => undefined);
48
+ }
49
+ async function assertLiveOwner(context, participant, expectedEpoch) {
50
+ if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
51
+ return true;
52
+ const identity = processIdentity(context.env ?? process.env);
53
+ // Library callers without a harness session are not ownership-fenced unless an epoch was supplied.
54
+ if (identity.channel === 'unknown' && expectedEpoch === undefined)
55
+ return true;
56
+ return sessionOwnsParticipant(context.location, participant, identity.session, context.env ?? process.env, expectedEpoch);
34
57
  }
35
58
  /** Presence is best effort and runs only after the artifact mutation commits. */
36
- async function ensureLocalPresence(context, participant) {
59
+ async function ensureLocalPresence(context, participant, epoch) {
37
60
  if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
38
61
  return;
39
62
  const identity = processIdentity(context.env ?? process.env);
63
+ const ownerEpoch = epoch ?? await currentOwnerEpoch(context, participant);
64
+ const now = context.clock();
65
+ // Semantic publish: an unexpired presence row already stands when it carries the same owner epoch.
66
+ try {
67
+ const existing = await context.hostLedger.listPresence({ location: context.location, participant, session: identity.session, scopes: ['local'], now });
68
+ if (existing.some((row) => row.channel === identity.channel
69
+ && now - (row.updatedAt ?? 0) < ROUTE_FRESH_MS
70
+ && (ownerEpoch === undefined || row.epoch === ownerEpoch)))
71
+ return;
72
+ }
73
+ catch { /* fall through to the best-effort ensure below */ }
40
74
  const result = await context.hostLedger.ensurePresence({
41
- location: context.location, participant, session: identity.session, channel: identity.channel, updatedAt: context.clock(),
42
- }, 'local').catch((error) => ({ status: 'degraded', record: { location: context.location, participant, session: identity.session, channel: identity.channel }, error }));
75
+ location: context.location,
76
+ participant,
77
+ session: identity.session,
78
+ channel: identity.channel,
79
+ // Presence rows are host-ledger wall-time evidence; the square clock belongs to artifact activities.
80
+ updatedAt: Date.now(),
81
+ ...(ownerEpoch === undefined || ownerEpoch <= 0 ? {} : { epoch: ownerEpoch }),
82
+ }, 'local').catch((error) => ({
83
+ status: 'degraded',
84
+ record: { location: context.location, participant, session: identity.session, channel: identity.channel },
85
+ error,
86
+ }));
43
87
  if (result.status === 'degraded')
44
88
  process.stderr.write(`! host presence degraded: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`);
45
89
  }
@@ -79,7 +123,17 @@ export async function catchUp(square, name, options = {}, project) {
79
123
  };
80
124
  }
81
125
  const remaining = deadline - Date.now();
82
- if (remaining <= 0 || !await square.artifact.changed(attempt.version, remaining)) {
126
+ if (remaining <= 0) {
127
+ return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
128
+ }
129
+ // Publication above is a semantic no-op while the route and presence stay unchanged and
130
+ // unexpired, so only a real external change can move the artifact version. Establish the
131
+ // wait baseline after those self-side effects; if the version moved anyway (own refresh or
132
+ // a racing external commit), re-snapshot instead of waiting on a stale baseline.
133
+ const baseline = (await square.artifact.read()).version;
134
+ if (baseline !== attempt.version)
135
+ continue;
136
+ if (!await square.artifact.changed(baseline, remaining)) {
83
137
  return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
84
138
  }
85
139
  }
@@ -118,49 +172,140 @@ function parseRequiredActivityId(id) {
118
172
  return index;
119
173
  }
120
174
  export async function join(square, name) {
175
+ // Rejected validation must not perform an ownership claim.
176
+ validateName(name);
177
+ const preview = await square.artifact.read();
178
+ const previewDecision = decideJoin(preview.state, name, square.clock());
179
+ if (previewDecision.joinAct === undefined) {
180
+ // An active artifact participant is idempotent only for its current session.
181
+ // A different owner must still pass through the registry CAS so it is refused
182
+ // instead of silently reconnecting as a foreign session.
183
+ if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
184
+ const identity = processIdentity(square.env ?? process.env);
185
+ const owner = await readParticipantOwner(square.location, previewDecision.joinedName, square.env ?? process.env);
186
+ if (owner === undefined) {
187
+ // Keep the claim path for an active artifact whose ledger owner is not
188
+ // visible yet; concurrent callers must still serialize through CAS.
189
+ }
190
+ else if (owner.sessionId !== identity.session) {
191
+ const userPresence = await square.hostLedger.listPresence({
192
+ location: square.location,
193
+ participant: previewDecision.joinedName,
194
+ scopes: ['user'],
195
+ });
196
+ if (userPresence.some((binding) => binding.session === owner.sessionId)) {
197
+ return { name: previewDecision.joinedName, activity: null };
198
+ }
199
+ // Continue into the ownership claim below; it will produce the stable
200
+ // already_joined error without mutating the artifact.
201
+ }
202
+ else {
203
+ return { name: previewDecision.joinedName, activity: null };
204
+ }
205
+ }
206
+ else {
207
+ return { name: previewDecision.joinedName, activity: null };
208
+ }
209
+ }
210
+ let epoch;
211
+ if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
212
+ const claim = await claimSessionParticipant(square.location, name, square.env ?? process.env);
213
+ epoch = claim?.epoch;
214
+ }
121
215
  const now = square.clock();
216
+ const route = await identityRouteDraft(square, name);
122
217
  const committed = await square.artifact.transact((state) => {
123
218
  const decision = decideJoin(state, name, now);
124
- if (decision.joinAct === undefined)
219
+ if (decision.joinAct === undefined) {
125
220
  return { result: { name: decision.joinedName, stored: null } };
221
+ }
222
+ if (square.location !== undefined && square.location !== 'memory')
223
+ dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
224
+ if (route !== undefined)
225
+ applyWakeRouteToState(state, route, now);
126
226
  return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
127
227
  });
128
- await ensureLocalPresence(square, committed.name);
129
- await publishIdentityRoute(square, committed.name);
228
+ await ensureLocalPresence(square, committed.name, epoch);
229
+ await publishIdentityRoute(square, committed.name, epoch);
130
230
  return { name: committed.name, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
131
231
  }
132
232
  /** End the standing participant and immediately let the caller reclaim the name. */
133
- export async function takeover(square, name) {
134
- const now = square.clock();
135
- const committed = await square.artifact.transact((state) => {
136
- const joinedName = resolveKnownName(state, name);
137
- const done = coreDone(state, joinedName, '', now);
138
- const storedDone = committedActivity(storeActs(state, [done]), 'kick');
139
- const decision = decideJoin(state, joinedName, now);
140
- if (decision.joinAct === undefined)
141
- throw new SquareError('already_joined', `${participantIdentity(joinedName)} could not be reclaimed`);
142
- const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
143
- state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
144
- return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
145
- });
233
+ export async function takeover(square, name, _oldSessionIds = []) {
234
+ // Rejected validation must not perform an ownership claim.
235
+ validateName(name);
236
+ const commitLifecycle = async () => {
237
+ const now = square.clock();
238
+ const committed = await square.artifact.transact((state) => {
239
+ const joinedName = resolveKnownName(state, name);
240
+ const done = coreDone(state, joinedName, '', now);
241
+ const storedDone = committedActivity(storeActs(state, [done]), 'kick');
242
+ const decision = decideJoin(state, joinedName, now);
243
+ if (decision.joinAct === undefined)
244
+ throw new SquareError('already_joined', `${participantIdentity(joinedName)} could not be reclaimed`);
245
+ const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
246
+ state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
247
+ return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
248
+ });
249
+ return committed;
250
+ };
251
+ if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
252
+ const env = square.env ?? process.env;
253
+ // Gate the ownership claim on the artifact's authoritative state: a takeover that cannot
254
+ // commit its lifecycle (a name that never joined, or a standing participant that is no
255
+ // longer joined) must not claim or remove presence. The refusals mirror the transaction.
256
+ const { state } = await square.artifact.read();
257
+ const joinedName = resolveKnownName(state, name); // invalid_args when the name never joined
258
+ coreDone(state, joinedName, '', square.clock()); // already_done/not_joined when not standing
259
+ const owner = await readParticipantOwner(square.location, name, env);
260
+ // The claim and the artifact lifecycle are one fenced critical section: a losing or refused
261
+ // takeover never mutates the winner, and no second takeover can interleave mid-commit.
262
+ const outcome = await claimSessionTakeover(square.location, name, env, {
263
+ expectedEpoch: owner?.epoch ?? 0,
264
+ expectedSession: owner?.sessionId ?? '',
265
+ }, async (claim) => {
266
+ const committed = await commitLifecycle();
267
+ await ensureLocalPresence(square, committed.name, claim.epoch);
268
+ await publishIdentityRoute(square, committed.name, claim.epoch);
269
+ return committed;
270
+ });
271
+ if (outcome.status === 'busy')
272
+ throw new SquareError('already_joined', `${participantIdentity(name)} is already bound to another session`);
273
+ return { name: outcome.result.name, activities: outcome.result.stored.map(exposeActivity), epoch: outcome.epoch };
274
+ }
275
+ const committed = await commitLifecycle();
276
+ await ensureLocalPresence(square, committed.name);
277
+ await publishIdentityRoute(square, committed.name);
146
278
  return { name: committed.name, activities: committed.stored.map(exposeActivity) };
147
279
  }
148
280
  export async function implicitJoin(square, name) {
149
281
  const now = square.clock();
282
+ const route = await identityRouteDraft(square, name);
150
283
  const committed = await square.artifact.transact((state) => {
151
284
  const decision = decideImplicitJoin(state, name, now);
285
+ if (decision.state === 'done') {
286
+ if (square.location !== undefined && square.location !== 'memory')
287
+ dropSessionWakeRoutesFromState(state, square.location, processIdentity(square.env ?? process.env).session);
288
+ return { state, result: { name: decision.joinedName, state: decision.state, stored: null } };
289
+ }
152
290
  if (decision.joinAct === undefined)
153
291
  return { result: { name: decision.joinedName, state: decision.state, stored: null } };
292
+ if (square.location !== undefined && square.location !== 'memory')
293
+ dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
294
+ if (route !== undefined)
295
+ applyWakeRouteToState(state, { ...route, participant: decision.joinedName }, now);
154
296
  return { state, result: { name: decision.joinedName, state: decision.state, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
155
297
  });
156
298
  await ensureLocalPresence(square, committed.name);
157
299
  if (committed.state === 'done')
158
300
  await retireIdentityRoute(square, committed.name);
159
- else
301
+ else if (committed.stored !== null)
160
302
  await publishIdentityRoute(square, committed.name);
161
303
  return { name: committed.name, state: committed.state, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
162
304
  }
163
305
  export async function express(square, name, body, options = {}) {
306
+ if (!await assertLiveOwner(square, name)) {
307
+ throw new SquareError('already_joined', `${participantIdentity(name)} is already bound to another session`);
308
+ }
164
309
  const now = square.clock();
165
310
  const reply = options.reply === undefined ? undefined : parseRequiredActivityId(options.reply);
166
311
  const committed = await square.artifact.transact((state) => {
@@ -206,16 +351,71 @@ async function landListenerChange(square, verb, actor, target) {
206
351
  export function listen(square, actor, target) { return landListenerChange(square, 'listen', actor, target); }
207
352
  export function ignore(square, actor, target) { return landListenerChange(square, 'ignore', actor, target); }
208
353
  export async function listening(square, actor) { const { state } = await square.artifact.read(); return coreListening(state, actor); }
209
- async function landCore(square, verb, actor, body = '') {
210
- const now = square.clock();
211
- const stored = await square.artifact.transact((state) => {
212
- const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
213
- return { state, result: committedActivity(storeActs(state, [act]), verb) };
214
- });
354
+ async function landCore(square, verb, actor, body = '', fence = {}) {
355
+ // Done completes ownership: the live-owner validation and the artifact commit share one
356
+ // ownership critical section, so a takeover finalizing between validation and commit can never
357
+ // be completed by a stale done — an old expected epoch refuses instead of appending.
358
+ const commit = async () => {
359
+ if (verb === 'done' && !await assertLiveOwner(square, actor, fence.expectedEpoch)) {
360
+ throw new SquareError('already_done', `${participantIdentity(actor)} is already bound to another session`);
361
+ }
362
+ const now = square.clock();
363
+ return square.artifact.transact((state) => {
364
+ const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
365
+ return { state, result: committedActivity(storeActs(state, [act]), verb) };
366
+ });
367
+ };
368
+ const fenced = verb === 'done' && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory';
369
+ const stored = fenced
370
+ ? await withOwnershipClaimLock(square.env ?? process.env, commit)
371
+ : await commit();
215
372
  if (verb === 'done')
216
- await retireIdentityRoute(square, actor);
373
+ await retireIdentityRoute(square, actor, fence.expectedEpoch);
217
374
  return { activity: exposeActivity(stored) };
218
375
  }
219
- export function done(square, name, body = '') { return landCore(square, 'done', name, body); }
376
+ export function done(square, name, body = '', fence = {}) {
377
+ return landCore(square, 'done', name, body, fence);
378
+ }
379
+ /** SessionEnd retires the ended session's routes even when its presence row is gone. */
380
+ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
381
+ const location = square.location;
382
+ const run = async () => {
383
+ const now = square.clock();
384
+ let currentSessionId;
385
+ let ownerMatches = expectedEpoch === undefined;
386
+ if (location !== undefined && location !== 'memory' && square.hostLedger !== undefined) {
387
+ try {
388
+ const bindings = await square.hostLedger.listPresence({ location, participant: name, scopes: ['user', 'local'], now });
389
+ currentSessionId = bindings.find((binding) => binding.session !== sessionId)?.session
390
+ ?? bindings.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]?.session;
391
+ if (expectedEpoch !== undefined) {
392
+ const owner = await readParticipantOwner(location, name, square.env ?? process.env);
393
+ ownerMatches = owner?.sessionId === sessionId && owner.epoch === expectedEpoch;
394
+ }
395
+ }
396
+ catch { /* stale evidence leaves ownership fenced by artifact routes */ }
397
+ }
398
+ return square.artifact.transact((state) => {
399
+ let committed = null;
400
+ if (location !== undefined && location !== 'memory' && ownerMatches && sessionCanEndParticipant(state, location, name, sessionId, currentSessionId)) {
401
+ try {
402
+ committed = committedActivity(storeActs(state, [coreDone(state, name, '', now)]), 'done');
403
+ }
404
+ catch (error) {
405
+ if (!isSquareError(error) || (error.code !== 'already_done' && error.code !== 'not_joined'))
406
+ throw error;
407
+ }
408
+ }
409
+ if (location !== undefined && location !== 'memory')
410
+ dropSessionWakeRoutesFromState(state, location, sessionId);
411
+ return { state, result: committed };
412
+ });
413
+ };
414
+ const fenced = location !== undefined && location !== 'memory' && square.hostLedger !== undefined;
415
+ const stored = fenced
416
+ ? await withOwnershipClaimLock(square.env ?? process.env, run)
417
+ : await run();
418
+ return stored === null ? null : { activity: exposeActivity(stored) };
419
+ }
220
420
  export function hold(square, name, reason = '') { return landCore(square, 'hold', name, reason); }
221
421
  export function resume(square, name) { return landCore(square, 'resume', name); }
@@ -86,6 +86,10 @@ export interface SquareBuildInput extends SquareSource {
86
86
  wakeTransport?: WakeTransportPort;
87
87
  env?: NodeJS.ProcessEnv;
88
88
  }
89
+ /** Optional fence for owner-scoped lifecycle actions. */
90
+ export interface OwnershipFenceOptions {
91
+ readonly expectedEpoch?: number;
92
+ }
89
93
  export interface Participant {
90
94
  readonly name: string;
91
95
  express(body: string, options?: ExpressOptions): Promise<ExpressResult>;
@@ -30,6 +30,13 @@ export declare function projectPresentationEvidence(input: {
30
30
  readonly activity?: string;
31
31
  readonly now?: number;
32
32
  }): Promise<readonly PresentationEvidenceProjection[]>;
33
+ /** Successful boundary output, including a clipped preview, suppresses awareness wake. */
34
+ export declare function presentationSuppressesWake(evidence: readonly Pick<PresentationEvidenceProjection, 'outcome'>[]): boolean;
35
+ /** The most recently published session owns wake eligibility for its participant. */
36
+ export declare function currentSessionBindings<T extends {
37
+ readonly participant: string;
38
+ readonly updatedAt?: number;
39
+ }>(bindings: readonly T[]): T[];
33
40
  export interface WakeAttempt {
34
41
  readonly at: number;
35
42
  readonly attention: {
@@ -50,6 +50,19 @@ export async function projectPresentationEvidence(input) {
50
50
  const rows = await input.hostLedger.listEvidence({ kind: 'presentation', location: input.location, participant: input.participant, session: input.sessionId, activity: input.activity, now: input.now });
51
51
  return rows.map((row) => ({ location: row.location, participant: row.participant, sessionId: row.session, activity: row.activity, outcome: row.outcome, ...(row.at === undefined ? {} : { at: row.at }) }));
52
52
  }
53
+ /** Successful boundary output, including a clipped preview, suppresses awareness wake. */
54
+ export function presentationSuppressesWake(evidence) {
55
+ return evidence.some((row) => row.outcome === 'clipped' || row.outcome === 'presented');
56
+ }
57
+ /** The most recently published session owns wake eligibility for its participant. */
58
+ export function currentSessionBindings(bindings) {
59
+ const latest = new Map();
60
+ for (const binding of bindings) {
61
+ const participant = nameKey(binding.participant);
62
+ latest.set(participant, Math.max(latest.get(participant) ?? Number.NEGATIVE_INFINITY, binding.updatedAt ?? 0));
63
+ }
64
+ return bindings.filter((binding) => (binding.updatedAt ?? 0) === latest.get(nameKey(binding.participant)));
65
+ }
53
66
  export function terminalWakeEvidence(attempts) { return attempts.findLast((attempt) => attempt.outcome === 'accepted'); }
54
67
  export function isWakeRouteAttemptable(route, attempts) {
55
68
  if (terminalWakeEvidence(attempts) !== undefined)
@@ -80,13 +93,13 @@ export async function projectWakeEvidenceFromState(input) {
80
93
  const presentedRows = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: canonicalLocation, now: input.now });
81
94
  return {
82
95
  evidence(recipient, actIndex) {
83
- const recipientBindings = bindings.filter((binding) => nameKey(binding.participant) === nameKey(recipient));
96
+ const recipientBindings = currentSessionBindings(bindings.filter((binding) => nameKey(binding.participant) === nameKey(recipient)));
84
97
  const routes = recipientBindings.flatMap((binding) => binding.route === undefined ? [] : [binding.route]);
85
98
  const attempts = recipientBindings.flatMap((binding) => attemptsByBinding.get(JSON.stringify([nameKey(recipient), actIndex, binding.sessionId])) ?? []);
86
99
  const terminal = terminalWakeEvidence(attempts);
87
- const presented = presentedRows.some((row) => row.activity === formatActivityId(actIndex) && row.participant.toLocaleLowerCase() === recipient.toLocaleLowerCase() && recipientBindings.some((binding) => binding.sessionId === row.sessionId));
100
+ const presented = presentedRows.some((row) => row.activity === formatActivityId(actIndex) && row.participant.toLocaleLowerCase() === recipient.toLocaleLowerCase() && recipientBindings.some((binding) => binding.sessionId === row.sessionId) && presentationSuppressesWake([row]));
88
101
  return { delivered: delivery.isSeen(recipient, actIndex), presented, attempts, ...(terminal === undefined ? {} : { terminal }), attemptableRoutes: terminal === undefined ? routes.filter((route) => isWakeRouteAttemptable(route, attempts)) : [] };
89
102
  },
90
103
  };
91
104
  }
92
- export function wakeIsEligible(evidence) { return !evidence.delivered && evidence.terminal === undefined && evidence.attemptableRoutes.length > 0; }
105
+ export function wakeIsEligible(evidence) { return !evidence.delivered && !evidence.presented && evidence.terminal === undefined && evidence.attemptableRoutes.length > 0; }
@@ -1,30 +1,70 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, writeSquareFile, } from './artifact.js';
4
5
  import { withFileLock } from './file-lock.js';
5
- import { LOCK_RETRY_MS, LOCK_STALE_MS } from './runtime.js';
6
+ import { LOCK_RETRY_MS } from './runtime.js';
6
7
  /**
7
8
  * The only production module allowed to cross the .square byte boundary.
8
9
  * Consumers receive a SquareState and never need to know which codec or lock
9
10
  * protects it.
10
11
  */
11
12
  export { createSquareState, };
13
+ async function canonicalPath(squarePath) {
14
+ const absolute = path.resolve(squarePath);
15
+ try {
16
+ return await fs.promises.realpath(absolute);
17
+ }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT')
20
+ throw error;
21
+ const parent = await fs.promises.realpath(path.dirname(absolute)).catch(() => path.dirname(absolute));
22
+ return path.join(parent, path.basename(absolute));
23
+ }
24
+ }
25
+ async function squareFileExists(squarePath) {
26
+ try {
27
+ await fs.promises.access(squarePath);
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ if (error.code === 'ENOENT')
32
+ return false;
33
+ throw error;
34
+ }
35
+ }
36
+ async function withSquareFileReadLock(squarePath, fn) {
37
+ if (!await squareFileExists(squarePath))
38
+ return fn();
39
+ try {
40
+ return await withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, createParent: false }, fn);
41
+ }
42
+ catch (error) {
43
+ if (!await squareFileExists(squarePath))
44
+ return fn();
45
+ throw error;
46
+ }
47
+ }
12
48
  export async function readSquareFile(squarePath) {
13
- return withSquareFileLock(squarePath, () => loadSquare(squarePath));
49
+ const canonical = await canonicalPath(squarePath);
50
+ return withSquareFileReadLock(canonical, () => loadSquare(canonical));
14
51
  }
15
52
  export async function probeSquareFile(squarePath) {
16
53
  if (!squarePath.endsWith('.square'))
17
54
  return undefined;
18
- return withSquareFileLock(squarePath, () => probeSquare(squarePath));
55
+ const canonical = await canonicalPath(squarePath);
56
+ return withSquareFileReadLock(canonical, () => probeSquare(canonical));
19
57
  }
20
58
  export async function diagnoseSquareFile(squarePath) {
21
- return withSquareFileLock(squarePath, () => diagnoseArtifactFile(squarePath));
59
+ const canonical = await canonicalPath(squarePath);
60
+ return withSquareFileReadLock(canonical, () => diagnoseArtifactFile(canonical));
22
61
  }
23
62
  export async function writeSquareSnapshot(squarePath, squareState) {
24
- await writeSquareFile(squarePath, squareState);
63
+ await writeSquareFile(await canonicalPath(squarePath), squareState);
25
64
  }
26
- export function withSquareFileLock(squarePath, fn) {
27
- return withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, fn);
65
+ export async function withSquareFileLock(squarePath, fn) {
66
+ const canonical = await canonicalPath(squarePath);
67
+ return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, fn);
28
68
  }
29
69
  function cloneState(squareState) {
30
70
  return structuredClone(squareState);
@@ -119,8 +159,13 @@ export function createFileCell(squarePath) {
119
159
  let version = 0;
120
160
  let fingerprint;
121
161
  let cached;
162
+ let canonicalSquarePath;
163
+ async function storagePath() {
164
+ canonicalSquarePath ??= await canonicalPath(squarePath);
165
+ return canonicalSquarePath;
166
+ }
122
167
  async function observe() {
123
- const next = await fileFingerprint(squarePath);
168
+ const next = await fileFingerprint(await storagePath());
124
169
  if (fingerprint === undefined) {
125
170
  fingerprint = next;
126
171
  }
@@ -135,21 +180,22 @@ export function createFileCell(squarePath) {
135
180
  const observed = await observe();
136
181
  if (cached?.fingerprint === observed)
137
182
  return cloneState(cached.state);
138
- const decoded = await loadSquare(squarePath);
183
+ const decoded = await loadSquare(await storagePath());
139
184
  cached = { fingerprint: observed, state: cloneState(decoded) };
140
185
  return cloneState(cached.state);
141
186
  }
142
187
  return {
143
188
  async transact(fn) {
144
189
  assertCellOpen(closed);
145
- return withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, async () => {
190
+ const canonical = await storagePath();
191
+ return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, async () => {
146
192
  assertCellOpen(closed);
147
193
  const current = await currentStateUnderLock();
148
194
  const working = cloneState(current);
149
195
  const outcome = fn(working, version);
150
196
  if (outcome.state !== undefined) {
151
- await writeSquareSnapshot(squarePath, outcome.state);
152
- fingerprint = await fileFingerprint(squarePath);
197
+ await writeSquareFile(canonical, outcome.state);
198
+ fingerprint = await fileFingerprint(canonical);
153
199
  cached = { fingerprint, state: cloneState(outcome.state) };
154
200
  version += 1;
155
201
  }
@@ -158,7 +204,7 @@ export function createFileCell(squarePath) {
158
204
  },
159
205
  async read() {
160
206
  assertCellOpen(closed);
161
- return withSquareFileLock(squarePath, async () => {
207
+ return withSquareFileReadLock(await storagePath(), async () => {
162
208
  assertCellOpen(closed);
163
209
  return { state: await currentStateUnderLock(), version };
164
210
  });
@@ -1,4 +1,4 @@
1
- import type { Activity, HistoryQuery, ParticipantStatus, SquareSnapshot } from './square-facade.js';
1
+ import type { Activity, ExpressResult, HistoryQuery, ParticipantStatus, SquareSnapshot } from './square-facade.js';
2
2
  import type { Participant, SquareAtInput, SquareBuildInput } from './square-facade.js';
3
3
  export declare class Square {
4
4
  readonly location: string;
@@ -24,6 +24,7 @@ export declare class Square {
24
24
  recognize(env: NodeJS.ProcessEnv): Promise<Participant | null>;
25
25
  close(): Promise<void>;
26
26
  reconcileBinding(): Promise<import("./host-ledger.js").ReconcileBindingResult>;
27
+ endOwnedSession(name: string, sessionId: string, expectedEpoch?: number): Promise<ExpressResult | null>;
27
28
  }
28
29
  export declare function markBoundarySeen(squarePath: string, name: string, actIndexes: readonly number[], at?: number): Promise<void>;
29
30
  export declare function openParticipant(input: SquareAtInput, name: string): Promise<{
@@ -1,6 +1,6 @@
1
1
  import { closeOpenSquare } from './open-square.js';
2
2
  import { buildMemorySquare, buildSquare, openSquare } from './square-file-adapter.js';
3
- import { catchUp, done, express, hold, ignore, implicitJoin, join, takeover, listen, listening, resume, } from './square-actions.js';
3
+ import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, resume, } from './square-actions.js';
4
4
  import { markBoundarySeen as recordBoundarySeen } from './presence.js';
5
5
  import { history, participantHistory, participants, resolveParticipant, snapshot } from './views.js';
6
6
  import { currentParticipant } from './views.js';
@@ -102,6 +102,7 @@ export class Square {
102
102
  }
103
103
  close() { return closeOpenSquare(this.square); }
104
104
  reconcileBinding() { return reconcileBindingOperation({ artifact: this.square.artifact, hostLedger: this.square.hostLedger, location: this.location }); }
105
+ endOwnedSession(name, sessionId, expectedEpoch) { return endOwnedSession(this.context, name, sessionId, expectedEpoch); }
105
106
  }
106
107
  export function markBoundarySeen(squarePath, name, actIndexes, at) {
107
108
  return recordBoundarySeen(squarePath, name, actIndexes, at);
@@ -4,7 +4,10 @@ export interface StateCell {
4
4
  transact<R>(fn: (state: SquareState, version: number) => {
5
5
  state?: SquareState;
6
6
  result: R;
7
- }): Promise<R>;
7
+ } | Promise<{
8
+ state?: SquareState;
9
+ result: R;
10
+ }>): Promise<R>;
8
11
  read(): Promise<{
9
12
  state: SquareState;
10
13
  version: number;
@@ -64,7 +64,10 @@ export async function recordWakeAttempt(attempt, env = process.env) {
64
64
  throw new Error(`${value.outcome} wake attempts require a transport signature.`);
65
65
  }
66
66
  const safe = redact(value, env.PASEO_PASSWORD);
67
- await ledger(env).appendWakeAttempt({ location: safe.attention.squarePath, participant: safe.attention.recipient, session: safe.session ?? safe.signature ?? `route:${safe.routeKind}`, activity: formatActivityId(safe.attention.actIndex), kind: 'wake', outcome: safe.outcome, routeKind: safe.routeKind, signature: safe.signature, attemptN: safe.attemptN, message: safe.message, diagnostic: safe.diagnostic, at: safe.at });
67
+ const session = safe.session ?? safe.signature ?? `route:${safe.routeKind}`;
68
+ const claim = await ledger(env).claimEvidence({ location: safe.attention.squarePath, participant: safe.attention.recipient, session, activity: formatActivityId(safe.attention.actIndex), kind: 'wake', leaseMs: 5000 });
69
+ if (claim.status === 'acquired')
70
+ await ledger(env).appendWakeAttempt({ location: safe.attention.squarePath, participant: safe.attention.recipient, session, activity: formatActivityId(safe.attention.actIndex), kind: 'wake', outcome: safe.outcome, routeKind: safe.routeKind, signature: safe.signature, attemptN: safe.attemptN, message: safe.message, diagnostic: safe.diagnostic, at: safe.at, claimToken: claim.claimToken });
68
71
  return value;
69
72
  }
70
73
  export async function recordRecoveredUnknown(attention, lease, env = process.env, at = Date.now()) {
@@ -94,7 +97,10 @@ export async function recordRecoveredUnknown(attention, lease, env = process.env
94
97
  attemptN: lease.attemptN,
95
98
  message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
96
99
  };
97
- await ledger(env).appendWakeAttempt({ location: attention.squarePath, participant: attention.recipient, session: lease.session ?? value.signature, activity: formatActivityId(attention.actIndex), kind: 'wake', outcome: 'unknown', routeKind, signature: value.signature, attemptN: value.attemptN, message: value.message, at });
100
+ const session = lease.session ?? value.signature;
101
+ const claim = await ledger(env).claimEvidence({ location: attention.squarePath, participant: attention.recipient, session, activity: formatActivityId(attention.actIndex), kind: 'wake', leaseMs: 5000 });
102
+ if (claim.status === 'acquired')
103
+ await ledger(env).appendWakeAttempt({ location: attention.squarePath, participant: attention.recipient, session, activity: formatActivityId(attention.actIndex), kind: 'wake', outcome: 'unknown', routeKind, signature: value.signature, attemptN: value.attemptN, message: value.message, at, claimToken: claim.claimToken });
98
104
  return value;
99
105
  }
100
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {