@astrosheep/square 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/help.js CHANGED
@@ -48,8 +48,9 @@ const COMMANDS = [
48
48
  { names: ['hold'], usage: '--as <name> hold [reason | -]', usesSquare: true, summary: 'Raise a hand and pause participant activity.' },
49
49
  { names: ['resume'], usage: '--as <name> resume', usesSquare: true, summary: 'Lower the raised hand and resume activity.' },
50
50
  {
51
- names: ['harness'], usage: 'harness <install <skills|claude|codex|opencode|pi> [-f] | uninstall codex | doctor [codex|opencode|delivery]>', usesSquare: true,
51
+ names: ['harness'], usage: 'harness <install <skills|claude|codex|opencode|pi> [-f] | uninstall <skills|claude|codex|opencode|pi> | doctor [skills|claude|codex|opencode|pi|delivery]>', usesSquare: true,
52
52
  summary: 'Install, remove, or diagnose official harness adapters.',
53
+ details: ['Targets:', ' skills, claude, codex, opencode, pi Install, remove, or diagnose one adapter.', ' delivery Diagnose delivery only; skips when no readable artifact is selected.'],
53
54
  },
54
55
  { names: ['compact'], usage: 'compact [--keep N]', usesSquare: true, summary: 'Archive older acts while retaining the latest N.' },
55
56
  {
package/dist/index.js CHANGED
@@ -13,13 +13,16 @@ export * from './presented.js';
13
13
  export * from './delivery-health.js';
14
14
  export * from './claude-hook.js';
15
15
  export * from './harness.js';
16
- export { extractMentions, countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, appendAct, withSquareLock, readCursor, } from './runtime.js';
16
+ export { extractMentions, countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, readCursor, } from './runtime.js';
17
+ export { appendAct, withSquareLock, writeSquareDoc } from './square-store.js';
17
18
  import { loadSquare } from './artifact.js';
18
19
  import { ackPeerDelta, indexedDelta, peerPublicActs, peerRoomChanges } from './activity-feed.js';
19
- import { dispatchActNotifications, hasDeliveredMention as hasDeliveredMentionImpl, matchesMentionTarget, waitForDeliveredMention as waitForDeliveredMentionImpl, } from './notifications.js';
20
+ import { hasDeliveredMention as hasDeliveredMentionImpl, matchesMentionTarget, waitForDeliveredMention as waitForDeliveredMentionImpl, } from './notifications.js';
20
21
  import { sameName } from './model.js';
21
- import { SLEEP_MS, WATCH_HEARTBEAT_MS, WATCH_STALE_MS, actStableIndex, appendAct, freshWatchLease, getReadState as getDocReadState, latestIndexedActIndex, markDeliveredMentions, readCursor, withSquareLock, writeSquareDoc, } from './runtime.js';
22
- import { decideAct, resolveKnownName } from './decisions.js';
22
+ import { SLEEP_MS, WATCH_HEARTBEAT_MS, WATCH_STALE_MS, actStableIndex, freshWatchLease, getReadState as getDocReadState, latestIndexedActIndex, markDeliveredMentions, readCursor, } from './runtime.js';
23
+ import { resolveKnownName } from './decisions.js';
24
+ import { execute } from './square-application.js';
25
+ import { withSquareLock, writeSquareDoc } from './square-store.js';
23
26
  export { WATCH_STALE_MS };
24
27
  function actRefIndex(ref) {
25
28
  if (typeof ref === 'number')
@@ -68,18 +71,32 @@ export function getParticipantPresence(squarePath, name, now = Date.now()) {
68
71
  export function isWatching(squarePath, name, now = Date.now()) {
69
72
  return getParticipantPresence(squarePath, name, now).watching;
70
73
  }
74
+ /** Typed participant intents share the same commit/effect pipeline as the CLI. */
75
+ export async function join(squarePath, name) {
76
+ await execute(squarePath, { type: 'join', name, now: Date.now() });
77
+ }
78
+ export async function done(squarePath, name, body = '') {
79
+ await execute(squarePath, { type: 'done', name, body, now: Date.now() });
80
+ }
81
+ export async function hold(squarePath, actor, body = '') {
82
+ await execute(squarePath, { type: 'hold', actor, body, now: Date.now() });
83
+ }
84
+ export async function resume(squarePath, actor) {
85
+ await execute(squarePath, { type: 'resume', actor, now: Date.now() });
86
+ }
87
+ export async function say(squarePath, name, body, opts = {}) {
88
+ await act(squarePath, name, body, opts);
89
+ }
71
90
  export async function act(squarePath, name, body, opts = {}) {
72
- const sent = await withSquareLock(squarePath, () => {
73
- const doc = loadSquare(squarePath);
74
- const decision = decideAct(doc, { name, body, force: opts.force ?? false, now: Date.now() });
75
- if (decision.type === 'sent') {
76
- const appended = appendAct(squarePath, doc, decision.act);
77
- return { act: appended, index: actStableIndex(appended) };
78
- }
79
- throw new Error(`Act rejected: ${decision.type}`);
91
+ const committed = await execute(squarePath, {
92
+ type: 'say',
93
+ name,
94
+ body,
95
+ force: opts.force ?? false,
96
+ now: Date.now(),
80
97
  });
81
- if (sent)
82
- await dispatchActNotifications(squarePath, sent);
98
+ if (committed.result.type !== 'sent')
99
+ throw new Error(`Act rejected: ${committed.result.type}`);
83
100
  }
84
101
  export async function* streamEvents(squarePath, opts = {}) {
85
102
  let doc = loadSquare(squarePath);
@@ -106,18 +123,20 @@ export async function* watch(squarePath, opts) {
106
123
  const name = resolveKnownName(doc, opts.name);
107
124
  const leaseId = `watch_api_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
108
125
  let nextHeartbeatAt = Date.now() + WATCH_HEARTBEAT_MS;
109
- await withSquareLock(squarePath, () => {
110
- doc = loadSquare(squarePath);
111
- const existing = freshWatchLease(doc, name);
112
- if (existing !== undefined)
113
- throw new Error(`${name} already has an active watch.`);
114
- const at = Date.now();
115
- nextHeartbeatAt = at + WATCH_HEARTBEAT_MS;
116
- const participants = byList(opts.by);
117
- const filter = { ...(participants ? { participants } : {}), ...(typeof opts.mention === 'string' ? { mention: opts.mention } : {}) };
118
- doc.runtime.leases[name] = { leaseId, heartbeatAt: at, expiresAt: at + WATCH_STALE_MS, ...(Object.keys(filter).length > 0 ? { filter } : {}) };
119
- writeSquareDoc(squarePath, doc);
126
+ const at = Date.now();
127
+ const participants = byList(opts.by);
128
+ const filter = { ...(participants ? { participants } : {}), ...(typeof opts.mention === 'string' ? { mention: opts.mention } : {}) };
129
+ const leased = await execute(squarePath, {
130
+ type: 'lease',
131
+ name,
132
+ leaseId,
133
+ at,
134
+ expiresAt: at + WATCH_STALE_MS,
135
+ filter: Object.keys(filter).length === 0 ? undefined : filter,
120
136
  });
137
+ if (leased.result.type === 'active')
138
+ throw new Error(`${name} already has an active watch.`);
139
+ nextHeartbeatAt = at + WATCH_HEARTBEAT_MS;
121
140
  try {
122
141
  while (true) {
123
142
  const yielded = await withSquareLock(squarePath, () => {
@@ -152,12 +171,6 @@ export async function* watch(squarePath, opts) {
152
171
  }
153
172
  }
154
173
  finally {
155
- await withSquareLock(squarePath, () => {
156
- const latest = loadSquare(squarePath);
157
- if (latest.runtime.leases[name]?.leaseId !== leaseId)
158
- return;
159
- delete latest.runtime.leases[name];
160
- writeSquareDoc(squarePath, latest);
161
- });
174
+ await execute(squarePath, { type: 'release-lease', name, leaseId });
162
175
  }
163
176
  }
package/dist/runtime.js CHANGED
@@ -1,8 +1,4 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { setTimeout as sleep } from 'node:timers/promises';
4
1
  import { fold } from './square-core.js';
5
- import { renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
6
2
  import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
7
3
  function parseIntegerEnvValue(name, raw, fallback) {
8
4
  if (raw === undefined)
@@ -242,23 +238,6 @@ export function markDeliveredMentions(doc, name, delivered, at = Date.now()) {
242
238
  }
243
239
  return changed;
244
240
  }
245
- export function writeSquareDoc(squarePath, doc) {
246
- const dir = path.dirname(squarePath);
247
- const base = path.basename(squarePath);
248
- const tempPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
249
- fs.writeFileSync(tempPath, renderSquareDoc(doc));
250
- fs.renameSync(tempPath, squarePath);
251
- saveRuntimeSidecar(squarePath, doc.runtime);
252
- }
253
- export function appendAct(squarePath, doc, act) {
254
- const indexed = { ...act, index: doc.runtime.nextActIndex };
255
- doc.runtime.nextActIndex++;
256
- doc.acts.push(indexed);
257
- if (indexed.actor !== undefined)
258
- touchPresenceCursor(doc, indexed.actor, indexed.at, indexed.kind === 'join' ? 'join' : 'api', actStableIndex(indexed));
259
- writeSquareDoc(squarePath, doc);
260
- return indexed;
261
- }
262
241
  export function latestIndexedActIndex(items) {
263
242
  return items.reduce((max, item) => Math.max(max, item.index), -1);
264
243
  }
@@ -269,36 +248,3 @@ export function freshWatchLease(doc, name, at = Date.now()) {
269
248
  return undefined;
270
249
  return lease;
271
250
  }
272
- export async function withSquareLock(squarePath, fn) {
273
- const lockPath = `${squarePath}.lock`;
274
- const lockDir = path.dirname(lockPath);
275
- fs.mkdirSync(lockDir, { recursive: true });
276
- while (true) {
277
- try {
278
- const fd = fs.openSync(lockPath, 'wx');
279
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
280
- fs.closeSync(fd);
281
- try {
282
- return await fn();
283
- }
284
- finally {
285
- try {
286
- fs.unlinkSync(lockPath);
287
- }
288
- catch { }
289
- }
290
- }
291
- catch (err) {
292
- const errno = err;
293
- if (errno.code !== 'EEXIST')
294
- throw err;
295
- try {
296
- const stat = fs.statSync(lockPath);
297
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS)
298
- fs.unlinkSync(lockPath);
299
- }
300
- catch { }
301
- await sleep(LOCK_RETRY_MS);
302
- }
303
- }
304
- }
@@ -0,0 +1,259 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { renderArtifactAct, renderSquare } from './artifact.js';
4
+ import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
5
+ import { partitionPendingDeliveries } from './delivery-health.js';
6
+ import { planRepair } from './doctor.js';
7
+ import { dispatchActNotifications } from './notifications.js';
8
+ import { squareStore } from './square-store.js';
9
+ import { actId, freshWatchLease, touchPresenceCursor } from './runtime.js';
10
+ /**
11
+ * Publish a dependent persistence file before the Square document. A retained
12
+ * backup lets a failed document commit restore the prior file exactly.
13
+ */
14
+ function prepareFileReplacement(filePath, text) {
15
+ const parent = path.dirname(filePath);
16
+ const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
17
+ const stage = path.join(parent, `.${path.basename(filePath)}.${token}.stage`);
18
+ const backup = path.join(parent, `.${path.basename(filePath)}.${token}.previous`);
19
+ let replaced = false;
20
+ let hadOriginal = false;
21
+ try {
22
+ fs.writeFileSync(stage, text);
23
+ hadOriginal = fs.existsSync(filePath);
24
+ if (hadOriginal)
25
+ fs.renameSync(filePath, backup);
26
+ fs.renameSync(stage, filePath);
27
+ replaced = true;
28
+ }
29
+ catch (error) {
30
+ try {
31
+ fs.rmSync(stage, { force: true });
32
+ }
33
+ catch { }
34
+ if (hadOriginal && fs.existsSync(backup) && !fs.existsSync(filePath)) {
35
+ try {
36
+ fs.renameSync(backup, filePath);
37
+ }
38
+ catch { }
39
+ }
40
+ throw error;
41
+ }
42
+ return {
43
+ rollback() {
44
+ if (replaced)
45
+ fs.rmSync(filePath, { force: true });
46
+ if (hadOriginal && fs.existsSync(backup))
47
+ fs.renameSync(backup, filePath);
48
+ },
49
+ finalize() {
50
+ fs.rmSync(backup, { force: true });
51
+ },
52
+ };
53
+ }
54
+ function plan(doc, intent) {
55
+ switch (intent.type) {
56
+ case 'join': {
57
+ const decision = decideJoin(doc, intent.name, intent.now);
58
+ return { result: decision, acts: [decision.joinAct], afterCommit: [] };
59
+ }
60
+ case 'say': {
61
+ const decision = decideAct(doc, intent);
62
+ return {
63
+ result: decision,
64
+ acts: decision.type === 'sent' ? [decision.act] : [],
65
+ afterCommit: [],
66
+ };
67
+ }
68
+ case 'hold':
69
+ return { result: undefined, acts: [coreHold(doc, intent.actor, intent.body, intent.now)], afterCommit: [] };
70
+ case 'resume':
71
+ return { result: undefined, acts: [coreResume(doc, intent.actor, intent.now)], afterCommit: [] };
72
+ case 'done':
73
+ return { result: undefined, acts: [coreDone(doc, intent.name, intent.body, intent.now)], afterCommit: [] };
74
+ case 'lease': {
75
+ const name = resolveKnownName(doc, intent.name);
76
+ const existing = freshWatchLease(doc, name, intent.at);
77
+ if (existing !== undefined && !intent.force) {
78
+ return { result: { type: 'active', lease: existing }, acts: [], afterCommit: [] };
79
+ }
80
+ return {
81
+ result: { type: 'started', name, replaced: existing !== undefined },
82
+ acts: [],
83
+ mutateRuntime: (runtime) => {
84
+ runtime.leases[name] = {
85
+ leaseId: intent.leaseId,
86
+ heartbeatAt: intent.at,
87
+ expiresAt: intent.expiresAt,
88
+ ...(intent.filter === undefined ? {} : { filter: intent.filter }),
89
+ };
90
+ touchPresenceCursor(doc, name, intent.at, 'watch');
91
+ },
92
+ afterCommit: [],
93
+ };
94
+ }
95
+ case 'release-lease': {
96
+ const name = resolveKnownName(doc, intent.name);
97
+ if (doc.runtime.leases[name]?.leaseId !== intent.leaseId) {
98
+ return { result: { released: false }, acts: [], afterCommit: [] };
99
+ }
100
+ return {
101
+ result: { released: true },
102
+ acts: [],
103
+ mutateRuntime: (runtime) => { delete runtime.leases[name]; },
104
+ afterCommit: [],
105
+ };
106
+ }
107
+ case 'consume': {
108
+ const name = resolveKnownName(doc, intent.name);
109
+ return {
110
+ result: { name },
111
+ acts: [],
112
+ mutateRuntime: (runtime) => {
113
+ const current = runtime.cursors[name];
114
+ runtime.cursors[name] = {
115
+ consumedThroughIndex: Math.max(current?.consumedThroughIndex ?? -1, intent.throughIndex),
116
+ updatedAt: Math.max(current?.updatedAt ?? 0, intent.at),
117
+ source: intent.source ?? 'watch',
118
+ };
119
+ },
120
+ afterCommit: [],
121
+ };
122
+ }
123
+ case 'compact': {
124
+ const result = coreCompact(doc, intent.keep);
125
+ const archive = result.archived;
126
+ return {
127
+ result,
128
+ acts: [],
129
+ replaceDoc: result.doc,
130
+ preparePersistence: archive.length === 0
131
+ ? undefined
132
+ : () => {
133
+ const existing = fs.existsSync(intent.archivePath) ? fs.readFileSync(intent.archivePath, 'utf8') : '';
134
+ const block = archive
135
+ .map((act, index) => renderArtifactAct(act, { first: existing === '' && index === 0 }))
136
+ .join('\n');
137
+ return prepareFileReplacement(intent.archivePath, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
138
+ },
139
+ afterCommit: [],
140
+ };
141
+ }
142
+ case 'reconcile-delivery-backlog': {
143
+ const { recent, historical } = partitionPendingDeliveries('', { now: intent.now, doc });
144
+ const actor = intent.actor ?? 'doctor --fix reconcile-backlog';
145
+ const receipts = historical.filter((item) => doc.runtime.mentionReceipts[item.recipient]?.[actId(item.actIndex)]?.status !== 'delivered');
146
+ const result = { reconciled: receipts.length, skippedRecent: recent.length, items: historical };
147
+ return {
148
+ result,
149
+ acts: [],
150
+ ...(receipts.length === 0
151
+ ? {}
152
+ : {
153
+ mutateRuntime: (runtime) => {
154
+ for (const item of receipts) {
155
+ const recipientReceipts = runtime.mentionReceipts[item.recipient] ?? {};
156
+ recipientReceipts[actId(item.actIndex)] = {
157
+ status: 'delivered',
158
+ at: intent.now,
159
+ reason: 'reconciled',
160
+ actor,
161
+ };
162
+ runtime.mentionReceipts[item.recipient] = recipientReceipts;
163
+ }
164
+ },
165
+ }),
166
+ afterCommit: [],
167
+ };
168
+ }
169
+ case 'repair':
170
+ return {
171
+ result: undefined,
172
+ acts: [],
173
+ replaceDoc: intent.doc,
174
+ preparePersistence: intent.quarantine === undefined || intent.quarantine.blocks.length === 0
175
+ ? undefined
176
+ : () => {
177
+ const existing = fs.existsSync(intent.quarantine.path) ? fs.readFileSync(intent.quarantine.path, 'utf8') : '';
178
+ const block = intent.quarantine.blocks.join('\n\n');
179
+ return prepareFileReplacement(intent.quarantine.path, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
180
+ },
181
+ afterCommit: [],
182
+ };
183
+ }
184
+ }
185
+ async function runEffects(squarePath, effects) {
186
+ for (const effect of effects) {
187
+ if (effect.type === 'dispatch-act-notifications') {
188
+ await dispatchActNotifications(squarePath, effect.item);
189
+ }
190
+ }
191
+ }
192
+ function commitPlan(squarePath, doc, planned) {
193
+ const nextDoc = planned.replaceDoc ?? doc;
194
+ const applied = squareStore.apply(nextDoc, planned.acts, planned.mutateRuntime);
195
+ const afterCommit = [
196
+ ...planned.afterCommit,
197
+ ...applied.acts.map((item) => ({ type: 'dispatch-act-notifications', item })),
198
+ ];
199
+ const committed = { result: planned.result, acts: applied.acts, afterCommit };
200
+ if (planned.acts.length === 0 && planned.mutateRuntime === undefined && planned.replaceDoc === undefined) {
201
+ return committed;
202
+ }
203
+ let persistence;
204
+ try {
205
+ persistence = planned.preparePersistence?.();
206
+ squareStore.commitOnce(squarePath, nextDoc, committed);
207
+ persistence?.finalize();
208
+ return committed;
209
+ }
210
+ catch (error) {
211
+ try {
212
+ persistence?.rollback();
213
+ }
214
+ catch { }
215
+ throw error;
216
+ }
217
+ }
218
+ /** The one mutation pipeline shared by package and CLI adapters. */
219
+ export async function execute(squarePath, intent) {
220
+ const committed = await squareStore.transact(squarePath, (doc) => commitPlan(squarePath, doc, plan(doc, intent)));
221
+ await runEffects(squarePath, committed.afterCommit);
222
+ return committed;
223
+ }
224
+ /** Application-owned artifact creation; adapters provide validated options and stdin text only. */
225
+ export async function createSquare(squarePath, options, snippet) {
226
+ await squareStore.create(squarePath, {
227
+ force: options.force,
228
+ text: renderSquare(options, snippet),
229
+ result: undefined,
230
+ });
231
+ }
232
+ export async function reconcileBacklog(squarePath, now = Date.now()) {
233
+ const committed = await execute(squarePath, {
234
+ type: 'reconcile-delivery-backlog',
235
+ now,
236
+ });
237
+ return committed.result;
238
+ }
239
+ /** Keep artifact repair planning and dependent quarantine persistence inside the application boundary. */
240
+ export async function repairSquare(squarePath) {
241
+ const result = await squareStore.transactText(squarePath, (text) => {
242
+ const repair = planRepair(text);
243
+ if (repair.diagnosis.unfixable || repair.repaired === undefined)
244
+ return { repair, afterCommit: [] };
245
+ const quarantinePath = squarePath.replace(/\.md$/, '') + '.quarantine.md';
246
+ const intent = {
247
+ type: 'repair',
248
+ doc: repair.repaired.doc,
249
+ ...(repair.repaired.quarantinedBlocks.length === 0
250
+ ? {}
251
+ : { quarantine: { path: quarantinePath, blocks: repair.repaired.quarantinedBlocks } }),
252
+ };
253
+ const committed = commitPlan(squarePath, repair.repaired.doc, plan(repair.repaired.doc, intent));
254
+ return { repair, afterCommit: committed.afterCommit };
255
+ });
256
+ await runEffects(squarePath, result.afterCommit);
257
+ return result.repair;
258
+ }
259
+ export const application = { execute, plan };
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { emptyRuntimeState, loadSquare, renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
5
+ import { SquareError } from './model.js';
6
+ import { LOCK_RETRY_MS, LOCK_STALE_MS, touchPresenceCursor } from './runtime.js';
7
+ /**
8
+ * The persistence boundary for Square documents. Callers may derive a plan from
9
+ * a loaded document, but only the store indexes acts and writes artifact/runtime
10
+ * state while the per-square lock is held.
11
+ */
12
+ export class SquareStore {
13
+ async transact(squarePath, operation) {
14
+ return withSquareLock(squarePath, async () => operation(loadSquare(squarePath)));
15
+ }
16
+ /** Recovery paths inspect raw text under the store lock before publishing a repair. */
17
+ async transactText(squarePath, operation) {
18
+ return withSquareLock(squarePath, async () => {
19
+ try {
20
+ return await operation(fs.readFileSync(squarePath, 'utf8'));
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') {
24
+ throw new SquareError('not_found', `square file not found: ${squarePath}`);
25
+ }
26
+ throw error;
27
+ }
28
+ });
29
+ }
30
+ /** Create the initial artifact under the same per-square lock as later commits. */
31
+ async create(squarePath, options) {
32
+ return withSquareLock(squarePath, () => {
33
+ if (fs.existsSync(squarePath) && !options.force) {
34
+ throw new SquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
35
+ }
36
+ const dir = path.dirname(squarePath);
37
+ const base = path.basename(squarePath);
38
+ const temporary = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(temporary, options.text);
41
+ fs.renameSync(temporary, squarePath);
42
+ saveRuntimeSidecar(squarePath, emptyRuntimeState(0));
43
+ return options.result;
44
+ });
45
+ }
46
+ apply(doc, acts, mutateRuntime) {
47
+ const indexed = [];
48
+ for (const act of acts) {
49
+ const stored = { ...act, index: doc.runtime.nextActIndex };
50
+ doc.runtime.nextActIndex += 1;
51
+ doc.acts.push(stored);
52
+ if (stored.actor !== undefined) {
53
+ touchPresenceCursor(doc, stored.actor, stored.at, stored.kind === 'join' ? 'join' : 'api', stored.index);
54
+ }
55
+ indexed.push({ act: stored, index: stored.index });
56
+ }
57
+ mutateRuntime?.(doc.runtime);
58
+ return { acts: indexed };
59
+ }
60
+ commitOnce(squarePath, doc, result) {
61
+ writeSquareDoc(squarePath, doc);
62
+ return result;
63
+ }
64
+ }
65
+ export const squareStore = new SquareStore();
66
+ export function writeSquareDoc(squarePath, doc) {
67
+ const dir = path.dirname(squarePath);
68
+ const base = path.basename(squarePath);
69
+ const tempPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
70
+ fs.writeFileSync(tempPath, renderSquareDoc(doc));
71
+ fs.renameSync(tempPath, squarePath);
72
+ saveRuntimeSidecar(squarePath, doc.runtime);
73
+ }
74
+ export function appendAct(squarePath, doc, act) {
75
+ const applied = squareStore.apply(doc, [act]);
76
+ squareStore.commitOnce(squarePath, doc, undefined);
77
+ return applied.acts[0].act;
78
+ }
79
+ export async function withSquareLock(squarePath, fn) {
80
+ const lockPath = `${squarePath}.lock`;
81
+ const lockDir = path.dirname(lockPath);
82
+ fs.mkdirSync(lockDir, { recursive: true });
83
+ while (true) {
84
+ try {
85
+ const fd = fs.openSync(lockPath, 'wx');
86
+ fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
87
+ fs.closeSync(fd);
88
+ try {
89
+ return await fn();
90
+ }
91
+ finally {
92
+ try {
93
+ fs.unlinkSync(lockPath);
94
+ }
95
+ catch { }
96
+ }
97
+ }
98
+ catch (err) {
99
+ const errno = err;
100
+ if (errno.code !== 'EEXIST')
101
+ throw err;
102
+ try {
103
+ const stat = fs.statSync(lockPath);
104
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS)
105
+ fs.unlinkSync(lockPath);
106
+ }
107
+ catch { }
108
+ await sleep(LOCK_RETRY_MS);
109
+ }
110
+ }
111
+ }