@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/attention-presentation.d.ts +1 -1
- package/dist/attention-presentation.js +1 -1
- package/dist/automatic-session.js +37 -22
- package/dist/boundary-presentation.js +1 -1
- package/dist/codex-boundary-state.js +2 -2
- package/dist/delivery-operations.d.ts +2 -2
- package/dist/delivery-operations.js +144 -45
- package/dist/file-lock.d.ts +5 -1
- package/dist/file-lock.js +16 -91
- package/dist/host-ledger-file-adapter.d.ts +6 -2
- package/dist/host-ledger-file-adapter.js +42 -23
- package/dist/host-ledger.d.ts +13 -3
- package/dist/notifications.js +32 -3
- package/dist/ports.d.ts +5 -2
- package/dist/presentation-operations.js +8 -6
- package/dist/registry.d.ts +40 -2
- package/dist/registry.js +141 -4
- package/dist/routes.d.ts +15 -3
- package/dist/routes.js +72 -12
- package/dist/runtime.d.ts +0 -1
- package/dist/runtime.js +0 -1
- package/dist/square-actions.d.ts +7 -4
- package/dist/square-actions.js +239 -39
- package/dist/square-facade.d.ts +4 -0
- package/dist/square-projections.d.ts +7 -0
- package/dist/square-projections.js +16 -3
- package/dist/square-storage.js +59 -13
- package/dist/square-wiring.d.ts +2 -1
- package/dist/square-wiring.js +2 -1
- package/dist/state-cell.d.ts +4 -1
- package/dist/wake-attempts.js +8 -2
- package/package.json +1 -1
package/dist/square-actions.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 = ''
|
|
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); }
|
package/dist/square-facade.d.ts
CHANGED
|
@@ -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; }
|
package/dist/square-storage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
const canonical = await canonicalPath(squarePath);
|
|
56
|
+
return withSquareFileReadLock(canonical, () => probeSquare(canonical));
|
|
19
57
|
}
|
|
20
58
|
export async function diagnoseSquareFile(squarePath) {
|
|
21
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
152
|
-
fingerprint = await fileFingerprint(
|
|
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
|
|
207
|
+
return withSquareFileReadLock(await storagePath(), async () => {
|
|
162
208
|
assertCellOpen(closed);
|
|
163
209
|
return { state: await currentStateUnderLock(), version };
|
|
164
210
|
});
|
package/dist/square-wiring.d.ts
CHANGED
|
@@ -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<{
|
package/dist/square-wiring.js
CHANGED
|
@@ -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);
|
package/dist/state-cell.d.ts
CHANGED
|
@@ -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
|
-
}
|
|
7
|
+
} | Promise<{
|
|
8
|
+
state?: SquareState;
|
|
9
|
+
result: R;
|
|
10
|
+
}>): Promise<R>;
|
|
8
11
|
read(): Promise<{
|
|
9
12
|
state: SquareState;
|
|
10
13
|
version: number;
|
package/dist/wake-attempts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|