@astrosheep/square 0.3.10 → 0.3.12
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/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/activity.js +6 -7
- package/dist/artifact.js +337 -618
- package/dist/boundary-presentation.js +1 -1
- package/dist/cli/context.js +3 -3
- package/dist/cli/harness-command.js +1 -1
- package/dist/cli/maintenance-commands.js +12 -58
- package/dist/cli/observation-commands.js +14 -34
- package/dist/cli/program.js +3 -6
- package/dist/cli/registry.js +1 -2
- package/dist/cli/square-commands.js +39 -20
- package/dist/cmd/notify-once.js +5 -15
- package/dist/compact.js +4 -4
- package/dist/decisions.js +21 -7
- package/dist/delivery-health.js +56 -136
- package/dist/delivery.js +11 -47
- package/dist/file-lock.js +112 -0
- package/dist/harness-codex.js +35 -29
- package/dist/harness-links.js +0 -3
- package/dist/harness-pi.js +57 -0
- package/dist/harness.js +10 -15
- package/dist/help.js +16 -18
- package/dist/index.js +11 -5
- package/dist/list.js +3 -47
- package/dist/model.js +4 -6
- package/dist/notifications.js +217 -32
- package/dist/paseo-connection.js +135 -0
- package/dist/paseo-delivery.js +73 -144
- package/dist/paseo-state.js +1 -1
- package/dist/paseo-timeline.js +32 -42
- package/dist/presentation.js +24 -39
- package/dist/presented.js +10 -72
- package/dist/registry.js +23 -24
- package/dist/routes.js +153 -0
- package/dist/runtime.js +6 -21
- package/dist/square-application.js +56 -127
- package/dist/square-core.js +56 -9
- package/dist/stream.js +1 -1
- package/dist/wake-attempts.js +175 -0
- package/dist/wake-evidence.js +35 -0
- package/dist/wake-port.js +22 -0
- package/dist/wake-sink.js +45 -6
- package/dist/watch.js +1 -2
- package/guides/participant.md +7 -174
- package/package.json +6 -3
- package/skills/brainstorm/SKILL.md +28 -28
- package/skills/square/.claude-plugin/plugin.json +1 -1
- package/skills/square/SKILL.md +23 -14
- package/skills/square-feedback/SKILL.md +7 -7
- package/dist/doctor.js +0 -35
- package/dist/notification-failures.js +0 -54
- package/template.md +0 -4
- package/templates/architect.md +0 -4
- package/templates/brainstorm.md +0 -4
package/dist/presented.js
CHANGED
|
@@ -2,13 +2,12 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { withFileLockSync } from './file-lock.js';
|
|
5
6
|
import { canonicalSquarePath, lookupParticipant, lookupSessionBindings } from './registry.js';
|
|
6
7
|
import { sameName } from './model.js';
|
|
7
8
|
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
9
|
const LOCK_STALE_MS = 5 * 60_000;
|
|
9
10
|
const LOCK_RETRY_MS = 10;
|
|
10
|
-
const lockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
11
|
-
const heldLocks = new Set();
|
|
12
11
|
export function presentedPath(env = process.env) {
|
|
13
12
|
return env.SQUARE_PRESENTED || path.join(os.homedir(), '.square', 'presented.ndjsonl');
|
|
14
13
|
}
|
|
@@ -33,7 +32,7 @@ function readRows(filePath, now = Date.now()) {
|
|
|
33
32
|
try {
|
|
34
33
|
const parsed = JSON.parse(line);
|
|
35
34
|
if (parsed.v !== 2 ||
|
|
36
|
-
typeof parsed.ts !== 'number' ||
|
|
35
|
+
typeof parsed.ts !== 'number' || !Number.isFinite(parsed.ts) ||
|
|
37
36
|
typeof parsed.owner_id !== 'string' ||
|
|
38
37
|
typeof parsed.square_path !== 'string' ||
|
|
39
38
|
typeof parsed.name !== 'string' ||
|
|
@@ -57,67 +56,6 @@ function writeRows(filePath, rows) {
|
|
|
57
56
|
});
|
|
58
57
|
fs.renameSync(temp, filePath);
|
|
59
58
|
}
|
|
60
|
-
function lockOwnerState(lockPath) {
|
|
61
|
-
let pid;
|
|
62
|
-
try {
|
|
63
|
-
pid = Number.parseInt(fs.readFileSync(lockPath, 'utf8').split('\n')[0], 10);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return 'unknown';
|
|
67
|
-
}
|
|
68
|
-
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
69
|
-
return 'unknown';
|
|
70
|
-
try {
|
|
71
|
-
process.kill(pid, 0);
|
|
72
|
-
return 'alive';
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
return error.code === 'ESRCH' ? 'dead' : 'alive';
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function withFileLock(lockPath, fn) {
|
|
79
|
-
if (heldLocks.has(lockPath))
|
|
80
|
-
throw new Error(`Reentrant presented lock: ${lockPath}`);
|
|
81
|
-
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
82
|
-
while (true) {
|
|
83
|
-
let acquired = false;
|
|
84
|
-
try {
|
|
85
|
-
const fd = fs.openSync(lockPath, 'wx', 0o600);
|
|
86
|
-
try {
|
|
87
|
-
fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
|
|
88
|
-
}
|
|
89
|
-
finally {
|
|
90
|
-
fs.closeSync(fd);
|
|
91
|
-
}
|
|
92
|
-
acquired = true;
|
|
93
|
-
heldLocks.add(lockPath);
|
|
94
|
-
return fn();
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
const errno = error;
|
|
98
|
-
if (acquired || errno.code !== 'EEXIST')
|
|
99
|
-
throw error;
|
|
100
|
-
try {
|
|
101
|
-
const stat = fs.statSync(lockPath);
|
|
102
|
-
if (lockOwnerState(lockPath) === 'dead' || Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
103
|
-
fs.unlinkSync(lockPath);
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
catch { }
|
|
108
|
-
Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
|
|
109
|
-
}
|
|
110
|
-
finally {
|
|
111
|
-
if (acquired) {
|
|
112
|
-
heldLocks.delete(lockPath);
|
|
113
|
-
try {
|
|
114
|
-
fs.unlinkSync(lockPath);
|
|
115
|
-
}
|
|
116
|
-
catch { }
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
59
|
function membershipKey(membership) {
|
|
122
60
|
return `${canonicalSquarePath(membership.squarePath)}\u0000${membership.name.toLocaleLowerCase()}`;
|
|
123
61
|
}
|
|
@@ -130,7 +68,7 @@ function withAttentionLocks(filePath, inbox, fn) {
|
|
|
130
68
|
function acquire(index) {
|
|
131
69
|
if (index >= lockPaths.length)
|
|
132
70
|
return fn();
|
|
133
|
-
return
|
|
71
|
+
return withFileLockSync(lockPaths[index], { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => acquire(index + 1));
|
|
134
72
|
}
|
|
135
73
|
return acquire(0);
|
|
136
74
|
}
|
|
@@ -154,24 +92,24 @@ function selectUnpresented(sessionId, inbox, rows) {
|
|
|
154
92
|
: [{ membership: { ...membership, notifications }, ownerId }];
|
|
155
93
|
});
|
|
156
94
|
}
|
|
157
|
-
export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env) {
|
|
95
|
+
export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env, now = Date.now()) {
|
|
158
96
|
const resolved = canonicalSquarePath(squarePath);
|
|
159
|
-
return readRows(presentedPath(env)).some((row) => row.owner_id === ownerId &&
|
|
97
|
+
return readRows(presentedPath(env), now).some((row) => row.owner_id === ownerId &&
|
|
160
98
|
canonicalSquarePath(row.square_path) === resolved &&
|
|
161
99
|
sameName(row.name, name) &&
|
|
162
100
|
row.act_index === actIndex);
|
|
163
101
|
}
|
|
164
102
|
/** True when any current participant owner has already received this attention. */
|
|
165
|
-
export function hasPresentedAttention(squarePath, name, actIndex, env = process.env) {
|
|
166
|
-
const ownerIds = new Set(lookupParticipant(squarePath, name).map((binding) => binding.ownerId));
|
|
103
|
+
export function hasPresentedAttention(squarePath, name, actIndex, env = process.env, now = Date.now()) {
|
|
104
|
+
const ownerIds = new Set(lookupParticipant(squarePath, name, now).map((binding) => binding.ownerId));
|
|
167
105
|
if (ownerIds.size === 0)
|
|
168
106
|
return false;
|
|
169
|
-
return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env));
|
|
107
|
+
return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env, now));
|
|
170
108
|
}
|
|
171
109
|
/**
|
|
172
110
|
* Serialize presentation only for the affected participants. Delivery runs
|
|
173
111
|
* outside the short ledger-write lock, so unrelated owners never wait on an
|
|
174
|
-
* adapter. A throwing callback leaves no row and remains
|
|
112
|
+
* adapter. A throwing callback leaves no row and remains unpresented.
|
|
175
113
|
*/
|
|
176
114
|
export function presentOnce(sessionId, lookup, deliver, env = process.env, at = Date.now()) {
|
|
177
115
|
const filePath = presentedPath(env);
|
|
@@ -185,7 +123,7 @@ export function presentOnce(sessionId, lookup, deliver, env = process.env, at =
|
|
|
185
123
|
if (selected.length === 0)
|
|
186
124
|
return undefined;
|
|
187
125
|
const result = deliver(selected.map(({ membership }) => membership));
|
|
188
|
-
|
|
126
|
+
withFileLockSync(`${filePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => {
|
|
189
127
|
const rows = readRows(filePath, at);
|
|
190
128
|
const known = new Set(rows.map(rowKey));
|
|
191
129
|
for (const { membership, ownerId } of selected) {
|
package/dist/registry.js
CHANGED
|
@@ -9,9 +9,9 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
|
-
import { loadSquare } from './artifact.js';
|
|
13
12
|
import { nameKey, sameName } from './model.js';
|
|
14
13
|
import { isCurrentlyJoined } from './runtime.js';
|
|
14
|
+
import { publishWakeRoutes, retireOwnerWakeRoutes } from './routes.js';
|
|
15
15
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
16
|
const COMPACT_BYTES = 64 * 1024;
|
|
17
17
|
const COMPACT_LINES = 1000;
|
|
@@ -231,19 +231,11 @@ export function localParticipantOwner(squarePath, name, env = process.env, now =
|
|
|
231
231
|
return undefined;
|
|
232
232
|
return lookupParticipant(squarePath, name, now).find((binding) => sessionIds.has(binding.sessionId))?.ownerId;
|
|
233
233
|
}
|
|
234
|
-
function bindingIsProvablyObsolete(binding) {
|
|
235
|
-
|
|
236
|
-
return true;
|
|
237
|
-
try {
|
|
238
|
-
return !isCurrentlyJoined(loadSquare(binding.squarePath).acts, binding.name);
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
// A temporarily unreadable artifact is uncertain, so preserve its binding.
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
234
|
+
function bindingIsProvablyObsolete(binding, acts) {
|
|
235
|
+
return acts !== undefined && !isCurrentlyJoined(acts, binding.name);
|
|
244
236
|
}
|
|
245
237
|
/** Compact the registry and remove only bindings disproved by their authoritative artifact. */
|
|
246
|
-
export function pruneRegistry(now = Date.now()) {
|
|
238
|
+
export function pruneRegistry(readActs, now = Date.now()) {
|
|
247
239
|
const filePath = registryPath();
|
|
248
240
|
let raw;
|
|
249
241
|
try {
|
|
@@ -255,7 +247,7 @@ export function pruneRegistry(now = Date.now()) {
|
|
|
255
247
|
throw error;
|
|
256
248
|
}
|
|
257
249
|
const active = foldRegistry(raw, now);
|
|
258
|
-
const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding));
|
|
250
|
+
const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding, readActs(binding.squarePath)));
|
|
259
251
|
writeRegistryBindings(filePath, kept);
|
|
260
252
|
return { removed: active.length - kept.length, kept: kept.length };
|
|
261
253
|
}
|
|
@@ -280,25 +272,29 @@ export function hasAutomaticDeliveryIdentity(env = process.env) {
|
|
|
280
272
|
export function recordLocalJoin(name, squarePath, env = process.env) {
|
|
281
273
|
const at = Date.now();
|
|
282
274
|
const identities = localSessionIdentities(env);
|
|
283
|
-
const identityIds = new Set(identities.map((identity) => identity.sessionId));
|
|
284
275
|
const current = lookupParticipant(squarePath, name, at);
|
|
285
|
-
const ownerId =
|
|
276
|
+
const ownerId = nextOwnerId();
|
|
277
|
+
for (const binding of current) {
|
|
278
|
+
recordDone(binding.sessionId, binding.name, binding.squarePath, {
|
|
279
|
+
channel: binding.channel,
|
|
280
|
+
child: binding.child,
|
|
281
|
+
...(binding.paseoAgentId ? { paseoAgentId: binding.paseoAgentId } : {}),
|
|
282
|
+
at,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
286
285
|
for (const identity of identities) {
|
|
287
286
|
recordJoin(identity.sessionId, name, squarePath, { ...identity, at, ownerId });
|
|
288
287
|
}
|
|
288
|
+
publishWakeRoutes(ownerId, { at, env });
|
|
289
|
+
for (const previousOwnerId of new Set(current.map((binding) => binding.ownerId))) {
|
|
290
|
+
if (previousOwnerId !== ownerId)
|
|
291
|
+
retireOwnerWakeRoutes(previousOwnerId, { at, env });
|
|
292
|
+
}
|
|
289
293
|
}
|
|
290
294
|
export function recordLocalDone(name, squarePath, env = process.env) {
|
|
291
295
|
const at = Date.now();
|
|
292
|
-
const identities = localSessionIdentities(env);
|
|
293
|
-
const identityIds = new Set(identities.map((identity) => identity.sessionId));
|
|
294
296
|
const current = lookupParticipant(squarePath, name, at);
|
|
295
|
-
const
|
|
296
|
-
if (ownerId === undefined) {
|
|
297
|
-
for (const identity of identities)
|
|
298
|
-
recordDone(identity.sessionId, name, squarePath, { ...identity, at });
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
for (const binding of current.filter((candidate) => candidate.ownerId === ownerId)) {
|
|
297
|
+
for (const binding of current) {
|
|
302
298
|
recordDone(binding.sessionId, binding.name, binding.squarePath, {
|
|
303
299
|
channel: binding.channel,
|
|
304
300
|
child: binding.child,
|
|
@@ -306,4 +302,7 @@ export function recordLocalDone(name, squarePath, env = process.env) {
|
|
|
306
302
|
at,
|
|
307
303
|
});
|
|
308
304
|
}
|
|
305
|
+
for (const ownerId of new Set(current.map((binding) => binding.ownerId))) {
|
|
306
|
+
retireOwnerWakeRoutes(ownerId, { at, env });
|
|
307
|
+
}
|
|
309
308
|
}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { WAKE_ROUTE_KINDS, isWakeRouteKind } from './model.js';
|
|
5
|
+
export { isWakeRouteKind, WAKE_ROUTE_KINDS } from './model.js';
|
|
6
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
export const ROUTE_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* Dispatch priority doubles as the kind declaration order: native kinds
|
|
10
|
+
* precede paseo. Within one kind, the freshest route wins.
|
|
11
|
+
*/
|
|
12
|
+
const ROUTE_KIND_PRIORITY = new Map(WAKE_ROUTE_KINDS.map((kind, index) => [kind, index]));
|
|
13
|
+
export function routesPath(env = process.env) {
|
|
14
|
+
return env.SQUARE_ROUTES || path.join(os.homedir(), '.square', 'routes.ndjsonl');
|
|
15
|
+
}
|
|
16
|
+
function routeKey(ownerId, kind) {
|
|
17
|
+
return `${ownerId}\0${kind}`;
|
|
18
|
+
}
|
|
19
|
+
function stringRecord(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
21
|
+
Object.values(value).every((item) => typeof item === 'string');
|
|
22
|
+
}
|
|
23
|
+
function parseRow(raw, now) {
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
value = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (value === null || typeof value !== 'object')
|
|
32
|
+
return undefined;
|
|
33
|
+
const row = value;
|
|
34
|
+
if (row.v !== 1 ||
|
|
35
|
+
(row.op !== 'upsert' && row.op !== 'retire') ||
|
|
36
|
+
typeof row.ts !== 'number' || !Number.isFinite(row.ts) || row.ts > now || now - row.ts > RETENTION_MS ||
|
|
37
|
+
typeof row.owner_id !== 'string' || row.owner_id === '' ||
|
|
38
|
+
typeof row.session_id !== 'string' || row.session_id === '' ||
|
|
39
|
+
!isWakeRouteKind(row.kind))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (row.op === 'upsert' && !stringRecord(row.address))
|
|
42
|
+
return undefined;
|
|
43
|
+
return row;
|
|
44
|
+
}
|
|
45
|
+
function readRows(env, now) {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = fs.readFileSync(routesPath(env), 'utf8');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return [];
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
return raw.split('\n').filter(Boolean).map((line) => parseRow(line, now)).filter((row) => row !== undefined);
|
|
56
|
+
}
|
|
57
|
+
export function readWakeRoutes(opts = {}) {
|
|
58
|
+
const now = opts.now ?? Date.now();
|
|
59
|
+
const state = new Map();
|
|
60
|
+
for (const row of readRows(opts.env ?? process.env, now)) {
|
|
61
|
+
const key = routeKey(row.owner_id, row.kind);
|
|
62
|
+
const current = state.get(key);
|
|
63
|
+
if (current === undefined || row.ts >= current.ts)
|
|
64
|
+
state.set(key, row);
|
|
65
|
+
}
|
|
66
|
+
return [...state.values()]
|
|
67
|
+
.filter((row) => row.op === 'upsert')
|
|
68
|
+
.map((row) => ({
|
|
69
|
+
ownerId: row.owner_id,
|
|
70
|
+
sessionId: row.session_id,
|
|
71
|
+
kind: row.kind,
|
|
72
|
+
address: row.address,
|
|
73
|
+
updatedAt: row.ts,
|
|
74
|
+
}))
|
|
75
|
+
.filter((route) => opts.ownerId === undefined || route.ownerId === opts.ownerId)
|
|
76
|
+
.filter((route) => opts.freshOnly !== true || now - route.updatedAt < ROUTE_FRESH_MS)
|
|
77
|
+
.sort((a, b) => (ROUTE_KIND_PRIORITY.get(a.kind) ?? 0) - (ROUTE_KIND_PRIORITY.get(b.kind) ?? 0) ||
|
|
78
|
+
b.updatedAt - a.updatedAt);
|
|
79
|
+
}
|
|
80
|
+
function appendRouteRow(row, env) {
|
|
81
|
+
const file = routesPath(env);
|
|
82
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
export function upsertWakeRoute(route, opts = {}) {
|
|
86
|
+
const at = opts.at ?? Date.now();
|
|
87
|
+
appendRouteRow({
|
|
88
|
+
v: 1,
|
|
89
|
+
ts: at,
|
|
90
|
+
op: 'upsert',
|
|
91
|
+
owner_id: route.ownerId,
|
|
92
|
+
session_id: route.sessionId,
|
|
93
|
+
kind: route.kind,
|
|
94
|
+
address: route.address,
|
|
95
|
+
}, opts.env ?? process.env);
|
|
96
|
+
}
|
|
97
|
+
export function retireOwnerWakeRoutes(ownerId, opts = {}) {
|
|
98
|
+
const at = opts.at ?? Date.now();
|
|
99
|
+
const env = opts.env ?? process.env;
|
|
100
|
+
for (const route of readWakeRoutes({ ownerId, now: at, env })) {
|
|
101
|
+
appendRouteRow({
|
|
102
|
+
v: 1,
|
|
103
|
+
ts: at,
|
|
104
|
+
op: 'retire',
|
|
105
|
+
owner_id: ownerId,
|
|
106
|
+
session_id: route.sessionId,
|
|
107
|
+
kind: route.kind,
|
|
108
|
+
}, env);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Publication requires a non-blank session and a non-empty address of non-blank values. */
|
|
112
|
+
function completeRouteEvidence(value) {
|
|
113
|
+
if (value === undefined || value === null)
|
|
114
|
+
return false;
|
|
115
|
+
if (typeof value.sessionId !== 'string' || value.sessionId.trim() === '')
|
|
116
|
+
return false;
|
|
117
|
+
const address = value.address;
|
|
118
|
+
if (address === null || typeof address !== 'object' || Array.isArray(address))
|
|
119
|
+
return false;
|
|
120
|
+
const entries = Object.entries(address);
|
|
121
|
+
return entries.length > 0 && entries.every(([key, item]) => key.trim() !== '' && typeof item === 'string' && item.trim() !== '');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One probe per kind. A probe publishes only when its provider's complete
|
|
125
|
+
* endpoint evidence is present; session identity alone never publishes. The
|
|
126
|
+
* four native transports have no endpoint lifecycle in this delivery, so
|
|
127
|
+
* their probes publish nothing until those transports land.
|
|
128
|
+
*/
|
|
129
|
+
export const WAKE_ROUTE_PROBES = {
|
|
130
|
+
'opencode-server': () => undefined,
|
|
131
|
+
'codex-app-server': () => undefined,
|
|
132
|
+
'claude-native': () => undefined,
|
|
133
|
+
'pi-extension': () => undefined,
|
|
134
|
+
paseo: (env) => {
|
|
135
|
+
const agentId = env.PASEO_AGENT_ID?.trim();
|
|
136
|
+
return agentId ? { sessionId: agentId, address: { agentId } } : undefined;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
/** The kind-neutral publication loop; probes supply complete evidence per kind. */
|
|
140
|
+
export function publishWakeRoutesFrom(ownerId, probes, opts = {}) {
|
|
141
|
+
const at = opts.at ?? Date.now();
|
|
142
|
+
const env = opts.env ?? process.env;
|
|
143
|
+
for (const kind of WAKE_ROUTE_KINDS) {
|
|
144
|
+
const evidence = probes[kind](env);
|
|
145
|
+
if (!completeRouteEvidence(evidence))
|
|
146
|
+
continue;
|
|
147
|
+
upsertWakeRoute({ ownerId, sessionId: evidence.sessionId, kind, address: evidence.address }, { at, env });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Publication boundary: every route written is complete provider evidence. */
|
|
151
|
+
export function publishWakeRoutes(ownerId, opts = {}) {
|
|
152
|
+
publishWakeRoutesFrom(ownerId, WAKE_ROUTE_PROBES, opts);
|
|
153
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fold } from './square-core.js';
|
|
1
|
+
import { audienceIncludes, audienceOf, fold, formatActivityId } from './square-core.js';
|
|
2
2
|
import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
|
|
3
3
|
function parseIntegerEnvValue(name, raw, fallback) {
|
|
4
4
|
if (raw === undefined)
|
|
@@ -51,27 +51,12 @@ export function nowMs() {
|
|
|
51
51
|
}
|
|
52
52
|
return value;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
const matches = [];
|
|
56
|
-
const re = /@([\p{L}\p{N}_-]+)/gu;
|
|
57
|
-
let match;
|
|
58
|
-
while ((match = re.exec(body)) !== null)
|
|
59
|
-
matches.push(match[1]);
|
|
60
|
-
return matches;
|
|
61
|
-
}
|
|
62
|
-
/** Pure directed-activity filter. Broadcast bodies (no @) match any named viewer. */
|
|
54
|
+
/** Pure directed-activity filter. Bell matches every viewer; mentions match by audience. */
|
|
63
55
|
export function matchesMentionTarget(act, mention) {
|
|
64
|
-
|
|
65
|
-
return true;
|
|
66
|
-
if (act.reach !== undefined) {
|
|
67
|
-
return mention === true || sameName(act.reach.beside, mention);
|
|
68
|
-
}
|
|
69
|
-
const mentions = extractMentions(act.body);
|
|
56
|
+
const audience = audienceOf(act);
|
|
70
57
|
if (mention === true)
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
return true;
|
|
74
|
-
return mentions.some((name) => sameName(name, mention));
|
|
58
|
+
return audience.kind === 'bell' || audience.names.length > 0;
|
|
59
|
+
return audienceIncludes(audience, mention);
|
|
75
60
|
}
|
|
76
61
|
export function foldedState(doc) {
|
|
77
62
|
return fold(doc.acts);
|
|
@@ -131,7 +116,7 @@ export function actStableIndex(act) {
|
|
|
131
116
|
}
|
|
132
117
|
export function actId(actOrIndex) {
|
|
133
118
|
const index = typeof actOrIndex === 'number' ? actOrIndex : actStableIndex(actOrIndex);
|
|
134
|
-
return
|
|
119
|
+
return formatActivityId(index);
|
|
135
120
|
}
|
|
136
121
|
export function getReadState(doc, name) {
|
|
137
122
|
return doc.runtime.cursors[name] ?? Object.entries(doc.runtime.cursors).find(([participant]) => sameName(participant, name))?.[1];
|
|
@@ -1,62 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
3
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
-
import { emptyRuntimeState, loadRuntimeSidecar, loadSquare, mergeRuntimeState, renderArtifactAct, renderSquare, renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
|
|
2
|
+
import { createSquareDoc, loadArchive, loadSquare, writeArchiveFile, writeSquareFile } from './artifact.js';
|
|
5
3
|
import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
|
|
6
|
-
import {
|
|
7
|
-
import { planRepair } from './doctor.js';
|
|
4
|
+
import { withFileLock } from './file-lock.js';
|
|
8
5
|
import { stageReplacement } from './harness-stage.js';
|
|
9
6
|
import { SquareError } from './model.js';
|
|
10
7
|
import { advanceCursor, freshWatchLease, LOCK_RETRY_MS, LOCK_STALE_MS, removeWatchLease, touchPresenceCursor, watchLease, writeWatchLease } from './runtime.js';
|
|
11
|
-
/** The only
|
|
8
|
+
/** The only mutation boundary: one per-square lock around one complete snapshot commit. */
|
|
12
9
|
export async function withSquareLock(squarePath, fn) {
|
|
13
|
-
|
|
14
|
-
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
15
|
-
while (true) {
|
|
16
|
-
let fd;
|
|
17
|
-
try {
|
|
18
|
-
fd = fs.openSync(lockPath, 'wx');
|
|
19
|
-
fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
if (fd !== undefined) {
|
|
23
|
-
try {
|
|
24
|
-
fs.closeSync(fd);
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
try {
|
|
28
|
-
fs.unlinkSync(lockPath);
|
|
29
|
-
}
|
|
30
|
-
catch { }
|
|
31
|
-
}
|
|
32
|
-
const errno = error;
|
|
33
|
-
if (errno.code !== 'EEXIST')
|
|
34
|
-
throw error;
|
|
35
|
-
try {
|
|
36
|
-
if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS)
|
|
37
|
-
fs.unlinkSync(lockPath);
|
|
38
|
-
}
|
|
39
|
-
catch { }
|
|
40
|
-
await sleep(LOCK_RETRY_MS);
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
fs.closeSync(fd);
|
|
44
|
-
try {
|
|
45
|
-
return await fn();
|
|
46
|
-
}
|
|
47
|
-
finally {
|
|
48
|
-
try {
|
|
49
|
-
fs.unlinkSync(lockPath);
|
|
50
|
-
}
|
|
51
|
-
catch { }
|
|
52
|
-
}
|
|
53
|
-
}
|
|
10
|
+
return withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, fn);
|
|
54
11
|
}
|
|
55
12
|
export function writeSquareDoc(squarePath, doc) {
|
|
56
|
-
|
|
57
|
-
fs.writeFileSync(temporary, renderSquareDoc(doc));
|
|
58
|
-
fs.renameSync(temporary, squarePath);
|
|
59
|
-
saveRuntimeSidecar(squarePath, doc.runtime);
|
|
13
|
+
writeSquareFile(squarePath, doc);
|
|
60
14
|
}
|
|
61
15
|
export function appendAct(squarePath, doc, act) {
|
|
62
16
|
const stored = applyActs(doc, [act])[0];
|
|
@@ -76,13 +30,11 @@ function applyActs(doc, acts, mutateRuntime) {
|
|
|
76
30
|
mutateRuntime?.(doc);
|
|
77
31
|
return stored;
|
|
78
32
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*/
|
|
83
|
-
function prepareAppend(filePath, block, existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '') {
|
|
33
|
+
/** Publish a dependent archive before the main snapshot, retaining rollback evidence. */
|
|
34
|
+
function prepareArchive(filePath, acts) {
|
|
35
|
+
const existing = fs.existsSync(filePath) ? loadArchive(filePath) : [];
|
|
84
36
|
return stageReplacement(filePath, (stage) => {
|
|
85
|
-
|
|
37
|
+
writeArchiveFile(stage, [...existing, ...acts]);
|
|
86
38
|
});
|
|
87
39
|
}
|
|
88
40
|
function plan(doc, intent) {
|
|
@@ -136,6 +88,50 @@ function plan(doc, intent) {
|
|
|
136
88
|
mutateRuntime: (nextDoc) => { removeWatchLease(nextDoc, name, intent.leaseId); },
|
|
137
89
|
};
|
|
138
90
|
}
|
|
91
|
+
case 'claim-notify': {
|
|
92
|
+
const current = doc.runtime.notifyLeases[intent.key];
|
|
93
|
+
if (current !== undefined && current.expiresAt > intent.at)
|
|
94
|
+
return { result: { type: 'busy' }, acts: [] };
|
|
95
|
+
if (current?.phase === 'dispatching')
|
|
96
|
+
return { result: { type: 'ambiguous', lease: current }, acts: [] };
|
|
97
|
+
return {
|
|
98
|
+
result: { type: 'acquired', leaseId: intent.leaseId },
|
|
99
|
+
acts: [],
|
|
100
|
+
mutateRuntime: (nextDoc) => {
|
|
101
|
+
nextDoc.runtime.notifyLeases[intent.key] = {
|
|
102
|
+
leaseId: intent.leaseId,
|
|
103
|
+
expiresAt: intent.expiresAt,
|
|
104
|
+
phase: 'claimed',
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case 'transition-notify': {
|
|
110
|
+
if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
|
|
111
|
+
return { result: { updated: false }, acts: [] };
|
|
112
|
+
return {
|
|
113
|
+
result: { updated: true },
|
|
114
|
+
acts: [],
|
|
115
|
+
mutateRuntime: (nextDoc) => {
|
|
116
|
+
nextDoc.runtime.notifyLeases[intent.key] = {
|
|
117
|
+
leaseId: intent.leaseId,
|
|
118
|
+
expiresAt: intent.expiresAt,
|
|
119
|
+
phase: intent.phase,
|
|
120
|
+
...(intent.attemptN === undefined ? {} : { attemptN: intent.attemptN }),
|
|
121
|
+
...(intent.routeKind === undefined ? {} : { routeKind: intent.routeKind }),
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'release-notify': {
|
|
127
|
+
if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
|
|
128
|
+
return { result: { released: false }, acts: [] };
|
|
129
|
+
return {
|
|
130
|
+
result: { released: true },
|
|
131
|
+
acts: [],
|
|
132
|
+
mutateRuntime: (nextDoc) => { delete nextDoc.runtime.notifyLeases[intent.key]; },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
139
135
|
case 'consume': {
|
|
140
136
|
const name = resolveKnownName(doc, intent.name);
|
|
141
137
|
return {
|
|
@@ -153,27 +149,9 @@ function plan(doc, intent) {
|
|
|
153
149
|
replaceDoc: result.doc,
|
|
154
150
|
preparePersistence: archive.length === 0
|
|
155
151
|
? undefined
|
|
156
|
-
: () =>
|
|
157
|
-
const existing = fs.existsSync(intent.archivePath) ? fs.readFileSync(intent.archivePath, 'utf8') : '';
|
|
158
|
-
const block = archive
|
|
159
|
-
.map((act, index) => renderArtifactAct(act, { first: existing === '' && index === 0 }))
|
|
160
|
-
.join('\n');
|
|
161
|
-
return prepareAppend(intent.archivePath, block, existing);
|
|
162
|
-
},
|
|
152
|
+
: () => prepareArchive(intent.archivePath, archive),
|
|
163
153
|
};
|
|
164
154
|
}
|
|
165
|
-
case 'repair':
|
|
166
|
-
return {
|
|
167
|
-
result: undefined,
|
|
168
|
-
acts: [],
|
|
169
|
-
replaceDoc: intent.doc,
|
|
170
|
-
preparePersistence: intent.quarantine === undefined || intent.quarantine.blocks.length === 0
|
|
171
|
-
? undefined
|
|
172
|
-
: () => {
|
|
173
|
-
const block = intent.quarantine.blocks.join('\n\n');
|
|
174
|
-
return prepareAppend(intent.quarantine.path, block);
|
|
175
|
-
},
|
|
176
|
-
};
|
|
177
155
|
}
|
|
178
156
|
}
|
|
179
157
|
function commitPlan(squarePath, doc, planned) {
|
|
@@ -199,15 +177,10 @@ function commitPlan(squarePath, doc, planned) {
|
|
|
199
177
|
}
|
|
200
178
|
/** The one mutation pipeline shared by package and CLI adapters. */
|
|
201
179
|
export async function execute(squarePath, intent) {
|
|
202
|
-
|
|
180
|
+
return withSquareLock(squarePath, () => {
|
|
203
181
|
const doc = loadSquare(squarePath);
|
|
204
182
|
return commitPlan(squarePath, doc, plan(doc, intent));
|
|
205
183
|
});
|
|
206
|
-
for (const act of committed.acts) {
|
|
207
|
-
if (act.kind === 'say')
|
|
208
|
-
await dispatchActNotifications(squarePath, act);
|
|
209
|
-
}
|
|
210
|
-
return committed;
|
|
211
184
|
}
|
|
212
185
|
/** Application-owned artifact creation; adapters provide validated options and stdin text only. */
|
|
213
186
|
export async function createSquare(squarePath, options, snippet) {
|
|
@@ -215,50 +188,6 @@ export async function createSquare(squarePath, options, snippet) {
|
|
|
215
188
|
if (fs.existsSync(squarePath) && !options.force) {
|
|
216
189
|
throw new SquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
|
|
217
190
|
}
|
|
218
|
-
|
|
219
|
-
fs.mkdirSync(path.dirname(squarePath), { recursive: true });
|
|
220
|
-
fs.writeFileSync(temporary, renderSquare(options, snippet));
|
|
221
|
-
fs.renameSync(temporary, squarePath);
|
|
222
|
-
saveRuntimeSidecar(squarePath, emptyRuntimeState(0));
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
/** Keep artifact repair planning and dependent quarantine persistence inside the application boundary. */
|
|
226
|
-
export async function repairSquare(squarePath) {
|
|
227
|
-
const result = await withSquareLock(squarePath, () => {
|
|
228
|
-
let text;
|
|
229
|
-
try {
|
|
230
|
-
text = fs.readFileSync(squarePath, 'utf8');
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
if (error.code === 'ENOENT')
|
|
234
|
-
throw new SquareError('not_found', `square file not found: ${squarePath}`);
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
237
|
-
const repair = planRepair(text);
|
|
238
|
-
if (repair.diagnosis.unfixable || repair.repaired === undefined)
|
|
239
|
-
return { repair };
|
|
240
|
-
// Repair changes Markdown only. Keep the sidecar's runtime metadata and
|
|
241
|
-
// merge history boundaries so a doctor run cannot erase delivery state or
|
|
242
|
-
// reuse a stable activity index.
|
|
243
|
-
const sidecarRuntime = loadRuntimeSidecar(squarePath, repair.repaired.doc.runtime);
|
|
244
|
-
const indexesPreserved = repair.diagnosis.acts.every(({ act }, index) => repair.repaired.doc.acts[index]?.index === act.index);
|
|
245
|
-
if (indexesPreserved) {
|
|
246
|
-
repair.repaired.doc.runtime = mergeRuntimeState(repair.repaired.doc.runtime, sidecarRuntime);
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
repair.repaired.doc.runtime = emptyRuntimeState(Math.max(repair.repaired.doc.runtime.nextActIndex, sidecarRuntime.nextActIndex));
|
|
250
|
-
repair.repaired.actions.push({ message: 'reset runtime delivery metadata because act indexes changed' });
|
|
251
|
-
}
|
|
252
|
-
const quarantinePath = squarePath.replace(/\.md$/, '') + '.quarantine.md';
|
|
253
|
-
const intent = {
|
|
254
|
-
type: 'repair',
|
|
255
|
-
doc: repair.repaired.doc,
|
|
256
|
-
...(repair.repaired.quarantinedBlocks.length === 0
|
|
257
|
-
? {}
|
|
258
|
-
: { quarantine: { path: quarantinePath, blocks: repair.repaired.quarantinedBlocks } }),
|
|
259
|
-
};
|
|
260
|
-
commitPlan(squarePath, repair.repaired.doc, plan(repair.repaired.doc, intent));
|
|
261
|
-
return { repair };
|
|
191
|
+
writeSquareFile(squarePath, createSquareDoc(options, snippet));
|
|
262
192
|
});
|
|
263
|
-
return result.repair;
|
|
264
193
|
}
|