@astrosheep/square 0.3.61 → 0.3.63
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/claude-plugin/skills/square/SKILL.md +5 -7
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/activity.d.ts +6 -2
- package/dist/activity.js +168 -133
- package/dist/artifact.d.ts +4 -0
- package/dist/artifact.js +43 -14
- package/dist/attention-presentation.d.ts +1 -0
- package/dist/attention-presentation.js +8 -6
- package/dist/automatic-session.js +14 -24
- package/dist/boundary-presentation.d.ts +1 -2
- package/dist/boundary-presentation.js +4 -19
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/context.js +2 -18
- package/dist/cli/harness-command.js +1 -1
- package/dist/cli/mcp-server-command.d.ts +2 -0
- package/dist/cli/mcp-server-command.js +13 -0
- package/dist/cli/observation-commands.js +47 -19
- package/dist/cli/program.js +5 -2
- package/dist/cli/registry.js +2 -0
- package/dist/cli/square-commands.d.ts +1 -1
- package/dist/cli/square-commands.js +93 -125
- package/dist/decisions.d.ts +2 -0
- package/dist/decisions.js +44 -3
- package/dist/delivery-operations.d.ts +1 -2
- package/dist/delivery-operations.js +1 -4
- package/dist/file-changes.d.ts +12 -0
- package/dist/file-changes.js +235 -0
- package/dist/help.js +4 -8
- package/dist/host-ledger-file-adapter.d.ts +15 -13
- package/dist/host-ledger-file-adapter.js +131 -79
- package/dist/host-ledger-root.d.ts +2 -0
- package/dist/host-ledger-root.js +9 -0
- package/dist/host-ledger.d.ts +4 -24
- package/dist/inbox.d.ts +10 -5
- package/dist/inbox.js +152 -67
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +122 -0
- package/dist/mention-parse.d.ts +8 -0
- package/dist/mention-parse.js +107 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +4 -1
- package/dist/notifications.d.ts +1 -0
- package/dist/notifications.js +25 -20
- package/dist/participant-host.js +2 -2
- package/dist/ports.d.ts +2 -9
- package/dist/presentation.d.ts +12 -1
- package/dist/presentation.js +42 -12
- package/dist/presented.js +2 -1
- package/dist/registry.d.ts +8 -4
- package/dist/registry.js +41 -74
- package/dist/routes.d.ts +19 -2
- package/dist/routes.js +48 -5
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +2 -0
- package/dist/square-actions.d.ts +26 -13
- package/dist/square-actions.js +200 -84
- package/dist/square-application.d.ts +58 -0
- package/dist/square-application.js +183 -0
- package/dist/square-facade.d.ts +24 -9
- package/dist/square-file-adapter.d.ts +5 -12
- package/dist/square-file-adapter.js +12 -64
- package/dist/square-projections.d.ts +3 -2
- package/dist/square-projections.js +12 -3
- package/dist/square-storage.js +27 -34
- package/dist/square-wiring.d.ts +12 -7
- package/dist/square-wiring.js +36 -32
- package/dist/state-cell.d.ts +1 -1
- package/dist/stream.js +30 -26
- package/dist/views.d.ts +3 -3
- package/dist/views.js +4 -4
- package/dist/wake-attempts.js +2 -1
- package/dist/wake-evidence.js +2 -8
- package/dist/wakes.d.ts +1 -0
- package/dist/wakes.js +5 -5
- package/dist/watch.d.ts +6 -1
- package/dist/watch.js +20 -18
- package/extensions/square-pi.js +100 -112
- package/guides/application-boundary.md +32 -0
- package/guides/architect.md +77 -77
- package/guides/brainstorm.md +197 -197
- package/guides/change-waits.md +28 -0
- package/guides/host-ledger.md +9 -0
- package/guides/mcp.md +9 -0
- package/guides/operation-cancellation.md +17 -0
- package/guides/participant.md +7 -7
- package/package.json +8 -2
- package/skills/square/SKILL.md +5 -7
package/dist/square-actions.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { formatActivityId, parseActivityId } from './square-core.js';
|
|
2
|
-
import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName } from './decisions.js';
|
|
2
|
+
import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName, validateDoneBody } from './decisions.js';
|
|
3
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 { claimSessionParticipant, claimSessionTakeover, readParticipantOwner, withOwnershipClaimLock } from './registry.js';
|
|
7
|
+
import { claimSessionParticipant, claimSessionTakeover, releaseSessionParticipantClaim, readParticipantOwner, withOwnershipClaimLock } from './registry.js';
|
|
8
8
|
import { assertLiveOwner, ensureLocalPresence, identityRouteDraft, processIdentity, publishIdentityRoute, retireIdentityRoute } from './participant-host.js';
|
|
9
|
-
import { applyWakeRouteToState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
|
|
9
|
+
import { applyWakeRouteToState, dropEndedSessionWakeRoutesFromState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
|
|
10
|
+
function throwIfAborted(control) { if (control?.signal?.aborted)
|
|
11
|
+
throw control.signal.reason ?? new Error('Operation aborted'); }
|
|
10
12
|
function exposeCaught(activity, perception) {
|
|
11
13
|
if (activity.kind === 'read' || activity.actor === undefined)
|
|
12
14
|
throw new Error(`Cannot expose stored activity ${formatActivityId(activity.index)}`);
|
|
13
15
|
const result = {
|
|
14
16
|
id: formatActivityId(activity.index), at: activity.at, kind: activity.kind, actor: activity.actor,
|
|
15
17
|
mentions: activity.kind === 'say' ? activity.mentions ?? [] : [],
|
|
18
|
+
...(activity.kind === 'say' && activity.reach !== undefined ? { reach: activity.reach } : {}),
|
|
16
19
|
...('body' in activity && activity.body !== undefined ? { body: activity.body } : {}),
|
|
17
20
|
...('target' in activity ? { target: activity.target } : {}),
|
|
18
21
|
...(activity.kind === 'say' && activity.reply !== undefined ? { reply: formatActivityId(activity.reply) } : {}),
|
|
@@ -22,11 +25,13 @@ function exposeCaught(activity, perception) {
|
|
|
22
25
|
const { body: _body, ...withoutBody } = result;
|
|
23
26
|
return { ...withoutBody, perception };
|
|
24
27
|
}
|
|
25
|
-
export async function catchUp(square, name, options = {}, project) {
|
|
28
|
+
export async function catchUp(square, name, options = {}, project, control) {
|
|
26
29
|
const idle = options.idle ?? 0;
|
|
27
30
|
if (!Number.isFinite(idle) || idle < 0)
|
|
28
31
|
throw new SquareError('invalid_args', 'Catch idle duration must be a non-negative number');
|
|
29
32
|
const deadline = Date.now() + idle;
|
|
33
|
+
if (control?.signal?.aborted)
|
|
34
|
+
throw control.signal.reason ?? new Error('Operation aborted');
|
|
30
35
|
while (true) {
|
|
31
36
|
if (!await assertLiveOwner(square, name)) {
|
|
32
37
|
throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
|
|
@@ -34,7 +39,7 @@ export async function catchUp(square, name, options = {}, project) {
|
|
|
34
39
|
const attempt = await square.artifact.transact((state, version) => {
|
|
35
40
|
const decision = decideCatch(state, name, options, square.clock(), project);
|
|
36
41
|
return { ...(decision.changed ? { state } : {}), result: { version, decision } };
|
|
37
|
-
});
|
|
42
|
+
}, control?.signal);
|
|
38
43
|
await ensureLocalPresence(square, name);
|
|
39
44
|
await publishIdentityRoute(square, name);
|
|
40
45
|
if (attempt.decision.delivered.length > 0 || idle === 0) {
|
|
@@ -53,10 +58,10 @@ export async function catchUp(square, name, options = {}, project) {
|
|
|
53
58
|
// unexpired, so only a real external change can move the artifact version. Establish the
|
|
54
59
|
// wait baseline after those self-side effects; if the version moved anyway (own refresh or
|
|
55
60
|
// a racing external commit), re-snapshot instead of waiting on a stale baseline.
|
|
56
|
-
const baseline = (await square.artifact.read()).version;
|
|
61
|
+
const baseline = (await square.artifact.read(control?.signal)).version;
|
|
57
62
|
if (baseline !== attempt.version)
|
|
58
63
|
continue;
|
|
59
|
-
if (!await square.artifact.changed(baseline, remaining)) {
|
|
64
|
+
if (!await square.artifact.changed(baseline, remaining, control?.signal)) {
|
|
60
65
|
return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
|
|
61
66
|
}
|
|
62
67
|
}
|
|
@@ -84,6 +89,7 @@ function exposeActivity(stored) {
|
|
|
84
89
|
id: formatActivityId(stored.index), at: stored.at, kind: stored.kind, actor: stored.actor,
|
|
85
90
|
...('body' in stored && stored.body !== undefined ? { body: stored.body } : {}),
|
|
86
91
|
mentions: stored.kind === 'say' ? stored.mentions ?? [] : [],
|
|
92
|
+
...(stored.kind === 'say' && stored.reach !== undefined ? { reach: stored.reach } : {}),
|
|
87
93
|
...('target' in stored ? { target: stored.target } : {}),
|
|
88
94
|
...(stored.kind === 'say' && stored.reply !== undefined ? { reply: formatActivityId(stored.reply) } : {}),
|
|
89
95
|
};
|
|
@@ -94,10 +100,11 @@ function parseRequiredActivityId(id) {
|
|
|
94
100
|
throw new SquareError('invalid_args', `Invalid activity id: ${id}`);
|
|
95
101
|
return index;
|
|
96
102
|
}
|
|
97
|
-
export async function join(square, name) {
|
|
103
|
+
export async function join(square, name, control) {
|
|
98
104
|
// Rejected validation must not perform an ownership claim.
|
|
105
|
+
throwIfAborted(control);
|
|
99
106
|
validateName(name);
|
|
100
|
-
const preview = await square.artifact.read();
|
|
107
|
+
const preview = await square.artifact.read(control?.signal);
|
|
101
108
|
const previewDecision = decideJoin(preview.state, name, square.clock());
|
|
102
109
|
if (previewDecision.joinAct === undefined) {
|
|
103
110
|
// An active artifact participant is idempotent only for its current session.
|
|
@@ -111,14 +118,6 @@ export async function join(square, name) {
|
|
|
111
118
|
// visible yet; concurrent callers must still serialize through CAS.
|
|
112
119
|
}
|
|
113
120
|
else if (owner.sessionId !== identity.session) {
|
|
114
|
-
const userPresence = await square.hostLedger.listPresence({
|
|
115
|
-
location: square.location,
|
|
116
|
-
participant: previewDecision.joinedName,
|
|
117
|
-
scopes: ['user'],
|
|
118
|
-
});
|
|
119
|
-
if (userPresence.some((binding) => binding.session === owner.sessionId)) {
|
|
120
|
-
return { name: previewDecision.joinedName, activity: null };
|
|
121
|
-
}
|
|
122
121
|
// Continue into the ownership claim below; it will produce the stable
|
|
123
122
|
// already_joined error without mutating the artifact.
|
|
124
123
|
}
|
|
@@ -131,32 +130,44 @@ export async function join(square, name) {
|
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
132
|
let epoch;
|
|
133
|
+
let ownershipClaim;
|
|
134
134
|
if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
|
|
135
|
-
const claim = await claimSessionParticipant(square.location, name, square.env ?? process.env);
|
|
135
|
+
const claim = await claimSessionParticipant(square.location, name, square.env ?? process.env, control?.signal);
|
|
136
|
+
ownershipClaim = claim;
|
|
136
137
|
epoch = claim?.epoch;
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
139
|
+
let committed;
|
|
140
|
+
try {
|
|
141
|
+
const now = square.clock();
|
|
142
|
+
const route = await identityRouteDraft(square, name);
|
|
143
|
+
throwIfAborted(control);
|
|
144
|
+
committed = await square.artifact.transact((state) => {
|
|
145
|
+
const decision = decideJoin(state, name, now);
|
|
146
|
+
if (decision.joinAct === undefined) {
|
|
147
|
+
return { result: { name: decision.joinedName, stored: null } };
|
|
148
|
+
}
|
|
149
|
+
if (square.location !== undefined && square.location !== 'memory')
|
|
150
|
+
dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
|
|
151
|
+
if (route !== undefined)
|
|
152
|
+
applyWakeRouteToState(state, route, now);
|
|
153
|
+
return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
|
|
154
|
+
}, control?.signal);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
await releaseSessionParticipantClaim(square.location, name, square.env ?? process.env, ownershipClaim).catch(() => undefined);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
151
160
|
await ensureLocalPresence(square, committed.name, epoch);
|
|
152
161
|
await publishIdentityRoute(square, committed.name, epoch);
|
|
153
162
|
return { name: committed.name, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
|
|
154
163
|
}
|
|
155
164
|
/** End the standing participant and immediately let the caller reclaim the name. */
|
|
156
|
-
export async function takeover(square, name, _oldSessionIds = []) {
|
|
165
|
+
export async function takeover(square, name, _oldSessionIds = [], control) {
|
|
157
166
|
// Rejected validation must not perform an ownership claim.
|
|
167
|
+
throwIfAborted(control);
|
|
158
168
|
validateName(name);
|
|
159
169
|
const commitLifecycle = async () => {
|
|
170
|
+
throwIfAborted(control);
|
|
160
171
|
const now = square.clock();
|
|
161
172
|
const committed = await square.artifact.transact((state) => {
|
|
162
173
|
const joinedName = resolveKnownName(state, name);
|
|
@@ -168,7 +179,7 @@ export async function takeover(square, name, _oldSessionIds = []) {
|
|
|
168
179
|
const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
|
|
169
180
|
state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
|
|
170
181
|
return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
|
|
171
|
-
});
|
|
182
|
+
}, control?.signal);
|
|
172
183
|
return committed;
|
|
173
184
|
};
|
|
174
185
|
if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
|
|
@@ -176,21 +187,23 @@ export async function takeover(square, name, _oldSessionIds = []) {
|
|
|
176
187
|
// Gate the ownership claim on the artifact's authoritative state: a takeover that cannot
|
|
177
188
|
// commit its lifecycle (a name that never joined, or a standing participant that is no
|
|
178
189
|
// longer joined) must not claim or remove presence. The refusals mirror the transaction.
|
|
179
|
-
const { state } = await square.artifact.read();
|
|
190
|
+
const { state } = await square.artifact.read(control?.signal);
|
|
180
191
|
const joinedName = resolveKnownName(state, name); // invalid_args when the name never joined
|
|
181
192
|
coreDone(state, joinedName, '', square.clock()); // already_done/not_joined when not standing
|
|
182
193
|
const owner = await readParticipantOwner(square.location, name, env);
|
|
194
|
+
throwIfAborted(control);
|
|
183
195
|
// The claim and the artifact lifecycle are one fenced critical section: a losing or refused
|
|
184
196
|
// takeover never mutates the winner, and no second takeover can interleave mid-commit.
|
|
185
197
|
const outcome = await claimSessionTakeover(square.location, name, env, {
|
|
186
198
|
expectedEpoch: owner?.epoch ?? 0,
|
|
187
199
|
expectedSession: owner?.sessionId ?? '',
|
|
188
200
|
}, async (claim) => {
|
|
201
|
+
throwIfAborted(control);
|
|
189
202
|
const committed = await commitLifecycle();
|
|
190
203
|
await ensureLocalPresence(square, committed.name, claim.epoch);
|
|
191
204
|
await publishIdentityRoute(square, committed.name, claim.epoch);
|
|
192
205
|
return committed;
|
|
193
|
-
});
|
|
206
|
+
}, control?.signal);
|
|
194
207
|
if (outcome.status === 'busy')
|
|
195
208
|
throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
|
|
196
209
|
return { name: outcome.result.name, activities: outcome.result.stored.map(exposeActivity), epoch: outcome.epoch };
|
|
@@ -200,9 +213,11 @@ export async function takeover(square, name, _oldSessionIds = []) {
|
|
|
200
213
|
await publishIdentityRoute(square, committed.name);
|
|
201
214
|
return { name: committed.name, activities: committed.stored.map(exposeActivity) };
|
|
202
215
|
}
|
|
203
|
-
export async function implicitJoin(square, name) {
|
|
216
|
+
export async function implicitJoin(square, name, control) {
|
|
217
|
+
throwIfAborted(control);
|
|
204
218
|
const now = square.clock();
|
|
205
219
|
const route = await identityRouteDraft(square, name);
|
|
220
|
+
throwIfAborted(control);
|
|
206
221
|
const committed = await square.artifact.transact((state) => {
|
|
207
222
|
const decision = decideImplicitJoin(state, name, now);
|
|
208
223
|
if (decision.state === 'done') {
|
|
@@ -217,7 +232,7 @@ export async function implicitJoin(square, name) {
|
|
|
217
232
|
if (route !== undefined)
|
|
218
233
|
applyWakeRouteToState(state, { ...route, participant: decision.joinedName }, now);
|
|
219
234
|
return { state, result: { name: decision.joinedName, state: decision.state, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
|
|
220
|
-
});
|
|
235
|
+
}, control?.signal);
|
|
221
236
|
await ensureLocalPresence(square, committed.name);
|
|
222
237
|
if (committed.state === 'done')
|
|
223
238
|
await retireIdentityRoute(square, committed.name);
|
|
@@ -225,31 +240,52 @@ export async function implicitJoin(square, name) {
|
|
|
225
240
|
await publishIdentityRoute(square, committed.name);
|
|
226
241
|
return { name: committed.name, state: committed.state, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
|
|
227
242
|
}
|
|
228
|
-
|
|
243
|
+
/** Only admission failures can advertise a safe retry; storage and post-commit
|
|
244
|
+
* failures must not be mistaken for a rejected send merely by their error code. */
|
|
245
|
+
function rejectUnsentActivity(operation) {
|
|
246
|
+
try {
|
|
247
|
+
return operation();
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (isSquareError(error))
|
|
251
|
+
error.facts = { ...error.facts, activityUnsent: true };
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function express(square, name, body, options = {}, control) {
|
|
256
|
+
if (control?.signal?.aborted)
|
|
257
|
+
throw control.signal.reason ?? new Error('Operation aborted');
|
|
229
258
|
if (!await assertLiveOwner(square, name)) {
|
|
230
|
-
throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name
|
|
259
|
+
throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`, { activityUnsent: true });
|
|
231
260
|
}
|
|
261
|
+
throwIfAborted(control);
|
|
232
262
|
const now = square.clock();
|
|
233
|
-
const reply = options.reply === undefined ? undefined : parseRequiredActivityId(options.reply);
|
|
263
|
+
const reply = rejectUnsentActivity(() => options.reply === undefined ? undefined : parseRequiredActivityId(options.reply));
|
|
234
264
|
const committed = await square.artifact.transact((state) => {
|
|
235
|
-
const decision =
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
265
|
+
const decision = rejectUnsentActivity(() => {
|
|
266
|
+
const decision = decideAct(state, { name, body, force: options.force ?? false, now, mentions: options.mentions, ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...(reply === undefined ? {} : { reply }) });
|
|
267
|
+
if (decision.type === 'blocked') {
|
|
268
|
+
const pending = decision.activitySummaries.reduce((count, summary) => count + summary.count, 0) + decision.unreadRoomChanges.length;
|
|
269
|
+
throw new SquareError('behind', `${participantIdentity(name)} has pending activity`, { pending });
|
|
270
|
+
}
|
|
271
|
+
if (decision.type === 'held') {
|
|
272
|
+
const holder = state.acts.filter((activity) => activity.kind === 'hold').at(-1)?.actor;
|
|
273
|
+
throw new SquareError('held', 'The square is held', {
|
|
274
|
+
...(holder === undefined ? {} : { holder }),
|
|
275
|
+
...(decision.reason === undefined ? {} : { holdReason: decision.reason }),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (decision.type === 'capped')
|
|
279
|
+
throw new SquareError('capped', `${participantIdentity(name)} reached the activity cap`);
|
|
280
|
+
if (decision.type === 'throttled')
|
|
281
|
+
throw new SquareError('throttled', `${name} is throttled`, { retryAfterMs: decision.delayMs });
|
|
282
|
+
if (decision.type === 'bell_quota')
|
|
283
|
+
throw new SquareError('bell_quota', `${participantIdentity(name)} cannot ring the bell yet`, { retryAfterMs: Math.max(1, decision.nextAt - now) });
|
|
284
|
+
return decision;
|
|
285
|
+
});
|
|
250
286
|
const stored = committedActivity(storeActs(state, [decision.act]), 'express');
|
|
251
287
|
return { state, result: { stored } };
|
|
252
|
-
});
|
|
288
|
+
}, control?.signal);
|
|
253
289
|
await ensureLocalPresence(square, name);
|
|
254
290
|
await publishIdentityRoute(square, name);
|
|
255
291
|
let delivery;
|
|
@@ -261,64 +297,118 @@ export async function express(square, name, body, options = {}) {
|
|
|
261
297
|
}
|
|
262
298
|
return { activity: exposeActivity(committed.stored), delivery };
|
|
263
299
|
}
|
|
264
|
-
async function landListenerChange(square, verb, actor, target) {
|
|
300
|
+
async function landListenerChange(square, verb, actor, target, control) {
|
|
301
|
+
throwIfAborted(control);
|
|
265
302
|
const now = square.clock();
|
|
266
303
|
const stored = await square.artifact.transact((state) => {
|
|
267
304
|
const act = verb === 'listen' ? coreListen(state, actor, target, now) : coreIgnore(state, actor, target, now);
|
|
268
305
|
if (act === undefined)
|
|
269
306
|
return { result: null };
|
|
270
307
|
return { state, result: committedActivity(storeActs(state, [act]), verb) };
|
|
271
|
-
});
|
|
308
|
+
}, control?.signal);
|
|
272
309
|
return { activity: stored === null ? null : exposeActivity(stored) };
|
|
273
310
|
}
|
|
274
|
-
export function listen(square, actor, target) { return landListenerChange(square, 'listen', actor, target); }
|
|
275
|
-
export function ignore(square, actor, target) { return landListenerChange(square, 'ignore', actor, target); }
|
|
276
|
-
export async function listening(square, actor) { const { state } = await square.artifact.read(); return coreListening(state, actor); }
|
|
277
|
-
async function landCore(square, verb, actor, body = '', fence = {}) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
311
|
+
export function listen(square, actor, target, control) { return landListenerChange(square, 'listen', actor, target, control); }
|
|
312
|
+
export function ignore(square, actor, target, control) { return landListenerChange(square, 'ignore', actor, target, control); }
|
|
313
|
+
export async function listening(square, actor, control) { const { state } = await square.artifact.read(control?.signal); return coreListening(state, actor); }
|
|
314
|
+
async function landCore(square, verb, actor, body = '', fence = {}, control) {
|
|
315
|
+
if (verb === 'done')
|
|
316
|
+
validateDoneBody(body);
|
|
317
|
+
// Done completes ownership: the live-owner validation, the artifact commit, the ended
|
|
318
|
+
// session's route retirement, and presence cleanup share one ownership critical section.
|
|
319
|
+
// A takeover finalizing between validation and commit can never be completed by a stale
|
|
320
|
+
// done, and no post-lock cleanup can remove a replacement owner's fresh rows.
|
|
321
|
+
const fenced = verb === 'done' && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory';
|
|
322
|
+
const session = fenced ? processIdentity(square.env ?? process.env).session : undefined;
|
|
323
|
+
const commitAndCleanup = async () => {
|
|
282
324
|
if (verb === 'done' && !await assertLiveOwner(square, actor, fence.expectedEpoch)) {
|
|
283
325
|
throw new SquareError('already_done', `✕ ${participantIdentity(actor)} already stands here — another session holds the name`);
|
|
284
326
|
}
|
|
327
|
+
let ownerRecords = [];
|
|
328
|
+
if (fenced && session !== undefined) {
|
|
329
|
+
ownerRecords = (await square.hostLedger.listPresence({ location: square.location, participant: actor }))
|
|
330
|
+
.filter((row) => row.session === session);
|
|
331
|
+
}
|
|
285
332
|
const now = square.clock();
|
|
286
|
-
|
|
333
|
+
const stored = await square.artifact.transact((state) => {
|
|
287
334
|
const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
|
|
288
|
-
|
|
289
|
-
|
|
335
|
+
const result = committedActivity(storeActs(state, [act]), verb);
|
|
336
|
+
if (fenced && session !== undefined) {
|
|
337
|
+
dropEndedSessionWakeRoutesFromState(state, square.location, session, { expectedEpoch: fence.expectedEpoch, force: true, participant: actor });
|
|
338
|
+
}
|
|
339
|
+
return { state, result };
|
|
340
|
+
}, control?.signal);
|
|
341
|
+
for (const ownerRecord of ownerRecords) {
|
|
342
|
+
await square.hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
|
|
343
|
+
process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return stored;
|
|
290
347
|
};
|
|
291
|
-
const fenced = verb === 'done' && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory';
|
|
292
348
|
const stored = fenced
|
|
293
|
-
? await withOwnershipClaimLock(square.env ?? process.env,
|
|
294
|
-
: await
|
|
295
|
-
if (verb === 'done')
|
|
296
|
-
await retireIdentityRoute(square, actor, fence.expectedEpoch);
|
|
349
|
+
? await withOwnershipClaimLock(square.env ?? process.env, commitAndCleanup, control?.signal)
|
|
350
|
+
: await commitAndCleanup();
|
|
297
351
|
return { activity: exposeActivity(stored) };
|
|
298
352
|
}
|
|
299
|
-
export function done(square, name, body = '', fence = {}) {
|
|
300
|
-
|
|
353
|
+
export function done(square, name, body = '', fence = {}, control) { return landCore(square, 'done', name, body, fence, control); }
|
|
354
|
+
function liveClaimedParticipants(rows, sessionId) {
|
|
355
|
+
const claimed = new Set();
|
|
356
|
+
const foreign = new Set();
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
const key = nameKey(row.participant);
|
|
359
|
+
if (row.session === sessionId)
|
|
360
|
+
claimed.add(key);
|
|
361
|
+
else
|
|
362
|
+
foreign.add(key);
|
|
363
|
+
}
|
|
364
|
+
for (const key of foreign)
|
|
365
|
+
claimed.delete(key);
|
|
366
|
+
return claimed;
|
|
301
367
|
}
|
|
302
|
-
|
|
368
|
+
async function readLiveClaimedParticipants(square, location, sessionId) {
|
|
369
|
+
if (square.hostLedger === undefined)
|
|
370
|
+
return new Set();
|
|
371
|
+
try {
|
|
372
|
+
return liveClaimedParticipants(await square.hostLedger.listPresence({ location, now: square.clock() }), sessionId);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* End one session's ownership of a participant inside the ownership claim critical
|
|
380
|
+
* section. The artifact `done` (when this session is still the owner) and retirement of
|
|
381
|
+
* this session's routes share one transaction, and the exact captured presence row is
|
|
382
|
+
* removed only while the lock is still held. A rejoin or takeover therefore lands either
|
|
383
|
+
* before the whole cleanup — and is part of the state being ended — or after it, with
|
|
384
|
+
* fresh presence and route rows that this cleanup can never touch.
|
|
385
|
+
*/
|
|
303
386
|
export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
|
|
304
387
|
const location = square.location;
|
|
305
388
|
const run = async () => {
|
|
306
389
|
const now = square.clock();
|
|
307
390
|
let currentSessionId;
|
|
308
391
|
let ownerMatches = expectedEpoch === undefined;
|
|
392
|
+
let ownerRecords = [];
|
|
393
|
+
let liveParticipants = new Set();
|
|
309
394
|
if (location !== undefined && location !== 'memory' && square.hostLedger !== undefined) {
|
|
310
395
|
try {
|
|
311
|
-
const
|
|
396
|
+
const rows = await square.hostLedger.listPresence({ location, now });
|
|
397
|
+
const bindings = rows.filter((row) => nameKey(row.participant) === nameKey(name));
|
|
312
398
|
currentSessionId = bindings.find((binding) => binding.session !== sessionId)?.session
|
|
313
399
|
?? bindings.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]?.session;
|
|
400
|
+
ownerRecords = rows.filter((row) => row.session === sessionId);
|
|
401
|
+
liveParticipants = liveClaimedParticipants(rows, sessionId);
|
|
314
402
|
if (expectedEpoch !== undefined) {
|
|
315
403
|
const owner = await readParticipantOwner(location, name, square.env ?? process.env);
|
|
316
404
|
ownerMatches = owner?.sessionId === sessionId && owner.epoch === expectedEpoch;
|
|
317
405
|
}
|
|
318
406
|
}
|
|
319
|
-
catch {
|
|
407
|
+
catch {
|
|
408
|
+
liveParticipants = undefined; /* unknown evidence preserves routes; artifact routes still fence ownership */
|
|
409
|
+
}
|
|
320
410
|
}
|
|
321
|
-
|
|
411
|
+
const stored = await square.artifact.transact((state) => {
|
|
322
412
|
let committed = null;
|
|
323
413
|
if (location !== undefined && location !== 'memory' && ownerMatches && sessionCanEndParticipant(state, location, name, sessionId, currentSessionId)) {
|
|
324
414
|
try {
|
|
@@ -329,10 +419,19 @@ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
|
|
|
329
419
|
throw error;
|
|
330
420
|
}
|
|
331
421
|
}
|
|
332
|
-
if (location !== undefined && location !== 'memory')
|
|
333
|
-
|
|
422
|
+
if (location !== undefined && location !== 'memory') {
|
|
423
|
+
dropEndedSessionWakeRoutesFromState(state, location, sessionId, { expectedEpoch, liveParticipants, force: committed !== null });
|
|
424
|
+
}
|
|
334
425
|
return { state, result: committed };
|
|
335
426
|
});
|
|
427
|
+
if (stored !== null) {
|
|
428
|
+
for (const ownerRecord of ownerRecords) {
|
|
429
|
+
await square.hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
|
|
430
|
+
process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return stored;
|
|
336
435
|
};
|
|
337
436
|
const fenced = location !== undefined && location !== 'memory' && square.hostLedger !== undefined;
|
|
338
437
|
const stored = fenced
|
|
@@ -340,5 +439,22 @@ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
|
|
|
340
439
|
: await run();
|
|
341
440
|
return stored === null ? null : { activity: exposeActivity(stored) };
|
|
342
441
|
}
|
|
343
|
-
|
|
344
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Retire an ended session's orphan routes when no participant name is known. The
|
|
444
|
+
* ownership claim lock is held across the ledger read and the artifact transaction, so a
|
|
445
|
+
* rejoin that claims first is seen as a live participant and its route survives.
|
|
446
|
+
*/
|
|
447
|
+
export async function retireEndedSessionRoutes(square, sessionId) {
|
|
448
|
+
const location = square.location;
|
|
449
|
+
if (location === undefined || location === 'memory' || square.hostLedger === undefined)
|
|
450
|
+
return;
|
|
451
|
+
await withOwnershipClaimLock(square.env ?? process.env, async () => {
|
|
452
|
+
const liveParticipants = await readLiveClaimedParticipants(square, location, sessionId);
|
|
453
|
+
await square.artifact.transact((state) => {
|
|
454
|
+
dropEndedSessionWakeRoutesFromState(state, location, sessionId, { liveParticipants, force: false });
|
|
455
|
+
return { state, result: undefined };
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
export function hold(square, name, reason = '', control) { return landCore(square, 'hold', name, reason, {}, control); }
|
|
460
|
+
export function resume(square, name, control) { return landCore(square, 'resume', name, '', {}, control); }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HostLedgerPort, WakeTransportPort } from './ports.js';
|
|
2
|
+
import { statusPresentation, participantsPresentation } from './views.js';
|
|
3
|
+
import type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, HistoryQuery, ListenerChangeResult, ParticipantStatus, PerceivedActivity, OperationControl } from './square-facade.js';
|
|
4
|
+
/** Final control coordinate shared by facade, actions, and application adapters. */
|
|
5
|
+
export interface SquareApplicationContext {
|
|
6
|
+
readonly cwd: string;
|
|
7
|
+
readonly env: NodeJS.ProcessEnv;
|
|
8
|
+
readonly squarePath?: string;
|
|
9
|
+
readonly participant?: string;
|
|
10
|
+
readonly clock?: () => number;
|
|
11
|
+
readonly hostLedger?: HostLedgerPort;
|
|
12
|
+
readonly wakeTransport?: WakeTransportPort;
|
|
13
|
+
}
|
|
14
|
+
export interface JoinApplicationOptions {
|
|
15
|
+
readonly takeover?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface JoinApplicationResult {
|
|
18
|
+
readonly participant: string;
|
|
19
|
+
readonly activity: Activity | null;
|
|
20
|
+
readonly kind: 'joined' | 'reconnected' | 'taken-over';
|
|
21
|
+
readonly participantCount: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ExpressApplicationOptions extends ExpressOptions {
|
|
24
|
+
readonly noWait?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface ExpressApplicationResult extends ExpressResult {
|
|
27
|
+
readonly waited: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ApplicationStatus {
|
|
30
|
+
readonly context: string;
|
|
31
|
+
readonly actCount: number;
|
|
32
|
+
readonly hardCap: number | null;
|
|
33
|
+
readonly throttlePerMinute?: number;
|
|
34
|
+
readonly held: {
|
|
35
|
+
readonly by: string;
|
|
36
|
+
readonly reason?: string;
|
|
37
|
+
} | null;
|
|
38
|
+
readonly participants: readonly ParticipantStatus[];
|
|
39
|
+
}
|
|
40
|
+
export type ApplicationParticipants = Awaited<ReturnType<typeof participantsPresentation>>;
|
|
41
|
+
export type ApplicationStatusProjection = Awaited<ReturnType<typeof statusPresentation>>;
|
|
42
|
+
export interface SquareApplication {
|
|
43
|
+
join(options?: JoinApplicationOptions, control?: OperationControl): Promise<JoinApplicationResult>;
|
|
44
|
+
express(body: string, options?: ExpressApplicationOptions, control?: OperationControl): Promise<ExpressApplicationResult>;
|
|
45
|
+
catch(options?: CatchOptions, control?: OperationControl): Promise<CatchResult>;
|
|
46
|
+
history(query?: HistoryQuery, control?: OperationControl): Promise<Activity[]>;
|
|
47
|
+
listen(target: string, control?: OperationControl): Promise<ListenerChangeResult>;
|
|
48
|
+
ignore(target: string, control?: OperationControl): Promise<ListenerChangeResult>;
|
|
49
|
+
listening(control?: OperationControl): Promise<readonly string[]>;
|
|
50
|
+
hold(reason?: string, control?: OperationControl): Promise<ExpressResult>;
|
|
51
|
+
resume(control?: OperationControl): Promise<ExpressResult>;
|
|
52
|
+
/** Leave without a message. Only an omitted or empty body is accepted. */
|
|
53
|
+
done(body?: string, control?: OperationControl): Promise<ExpressResult>;
|
|
54
|
+
status(): Promise<ApplicationStatusProjection>;
|
|
55
|
+
participants(): Promise<ApplicationParticipants>;
|
|
56
|
+
}
|
|
57
|
+
export declare function createSquareApplication(context: SquareApplicationContext): SquareApplication;
|
|
58
|
+
export type { CatchResult, PerceivedActivity };
|