@spexcode/spec-cli 0.6.5 → 0.6.6
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/bin/spex.mjs +2 -1
- package/dist/claude-headless.d.ts +4 -1
- package/dist/claude-headless.js +13 -4
- package/dist/cli.js +72 -23
- package/dist/client.d.ts +2 -1
- package/dist/client.js +13 -8
- package/dist/codex-runtime-generations.d.ts +5 -0
- package/dist/codex-runtime-generations.js +112 -0
- package/dist/doctor.js +7 -1
- package/dist/gateway-hub.js +7 -5
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.js +44 -20
- package/dist/graphCache.js +2 -1
- package/dist/graphSnapshot.js +2 -1
- package/dist/harness.d.ts +15 -2
- package/dist/harness.js +174 -54
- package/dist/help.d.ts +5 -0
- package/dist/help.js +26 -6
- package/dist/index.js +3 -3
- package/dist/listen.d.ts +2 -1
- package/dist/listen.js +10 -10
- package/dist/opencode-headless.d.ts +1 -0
- package/dist/opencode-headless.js +7 -0
- package/dist/runtime-rotate.d.ts +1 -0
- package/dist/runtime-rotate.js +58 -0
- package/dist/session-follow.js +1 -1
- package/dist/session-timeline.d.ts +6 -46
- package/dist/session-timeline.js +8 -221
- package/dist/sessions.d.ts +25 -4
- package/dist/sessions.js +822 -394
- package/dist/supervise.js +3 -3
- package/package.json +6 -4
- package/dist/delivery-queue.d.ts +0 -23
- package/dist/delivery-queue.js +0 -179
- package/dist/session-cursors.d.ts +0 -14
- package/dist/session-cursors.js +0 -82
package/dist/supervise.js
CHANGED
|
@@ -42,7 +42,7 @@ const childApiBase = `http://127.0.0.1:${proxyPort}`;
|
|
|
42
42
|
// observe the source closure, compile it, then make the supervisor's normal zero-downtime swap. A source edit
|
|
43
43
|
// must never cause a restart that still runs the old dist.
|
|
44
44
|
const watchRoots = workspaceRoot
|
|
45
|
-
? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src')]
|
|
45
|
+
? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src'), join(workspaceRoot, 'packages', 'session-core', 'src')]
|
|
46
46
|
: [here];
|
|
47
47
|
function buildWorkspace() {
|
|
48
48
|
if (!workspaceRoot)
|
|
@@ -246,11 +246,11 @@ catch { /* already gone */ } };
|
|
|
246
246
|
if (publicCfg) {
|
|
247
247
|
// public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
|
|
248
248
|
const distDir = resolveDistDir();
|
|
249
|
-
listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () =>
|
|
249
|
+
listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => recordEndpoint(childApiBase), ready: `spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})` });
|
|
250
250
|
startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild });
|
|
251
251
|
}
|
|
252
252
|
else {
|
|
253
|
-
listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () =>
|
|
253
|
+
listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => recordEndpoint(childApiBase), ready: `spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})` });
|
|
254
254
|
}
|
|
255
255
|
startResourceMonitor();
|
|
256
256
|
// Watch the source closure in a workspace and the emitted directory in an installed package. Debounce a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"bundledDependencies": [
|
|
23
23
|
"@spexcode/spec-core",
|
|
24
|
+
"@spexcode/session-core",
|
|
24
25
|
"@spexcode/spec-eval",
|
|
25
26
|
"@spexcode/spec-forge"
|
|
26
27
|
],
|
|
@@ -35,9 +36,10 @@
|
|
|
35
36
|
"test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@spexcode/
|
|
39
|
-
"@spexcode/spec-
|
|
40
|
-
"@spexcode/spec-
|
|
39
|
+
"@spexcode/session-core": "0.6.6",
|
|
40
|
+
"@spexcode/spec-core": "0.6.6",
|
|
41
|
+
"@spexcode/spec-eval": "0.6.6",
|
|
42
|
+
"@spexcode/spec-forge": "0.6.6"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@types/node": "^20.16.0",
|
package/dist/delivery-queue.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export type PendingMessage = {
|
|
2
|
-
mid: string;
|
|
3
|
-
text: string;
|
|
4
|
-
from: string | null;
|
|
5
|
-
dispatch?: {
|
|
6
|
-
operation: 'merge';
|
|
7
|
-
requestDigest: string;
|
|
8
|
-
};
|
|
9
|
-
};
|
|
10
|
-
export declare function revokeSenderDelivery(id: string): void;
|
|
11
|
-
export declare const senderDeliveryRevoked: (id: string) => boolean;
|
|
12
|
-
export declare function pendingSnapshot(id: string): PendingMessage[];
|
|
13
|
-
export declare function replacePendingWhileLocked(id: string, msgs: PendingMessage[]): void;
|
|
14
|
-
export declare function revokePendingFromWhileLocked(id: string, sender: string): number;
|
|
15
|
-
export declare function enqueue(id: string, msg: PendingMessage): void;
|
|
16
|
-
export declare function ensurePendingWhileLocked(id: string, msg: PendingMessage): boolean;
|
|
17
|
-
export declare const pendingMessages: (id: string) => PendingMessage[];
|
|
18
|
-
export declare const owesDelivery: (id: string) => boolean;
|
|
19
|
-
export declare function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index?: number, ids?: string[]): Promise<T>;
|
|
20
|
-
export declare function drain(id: string, insert: (msg: PendingMessage) => Promise<boolean>, timeoutMs?: number): Promise<{
|
|
21
|
-
delivered: number;
|
|
22
|
-
remaining: number;
|
|
23
|
-
}>;
|
package/dist/delivery-queue.js
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from '@spexcode/spec-core';
|
|
4
|
-
const queuePath = (id) => sessionArtifactPath(id, 'pending.json');
|
|
5
|
-
const revokedSenderRoot = () => join(runtimeRoot(), '.revoked-senders');
|
|
6
|
-
const revokedSenderPath = (id) => join(revokedSenderRoot(), id);
|
|
7
|
-
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
8
|
-
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
9
|
-
// record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
|
|
10
|
-
// and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
|
|
11
|
-
// so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
|
|
12
|
-
const lockRoot = () => join(runtimeRoot(), '.delivery-locks');
|
|
13
|
-
const lockPath = (id) => join(lockRoot(), `${id}.lock`);
|
|
14
|
-
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
-
async function acquire(id, timeoutMs) {
|
|
16
|
-
mkdirSync(lockRoot(), { recursive: true });
|
|
17
|
-
const path = lockPath(id), deadline = Date.now() + timeoutMs;
|
|
18
|
-
for (;;) {
|
|
19
|
-
try {
|
|
20
|
-
const fd = openSync(path, 'wx');
|
|
21
|
-
writeSync(fd, String(process.pid));
|
|
22
|
-
closeSync(fd);
|
|
23
|
-
return () => { try {
|
|
24
|
-
unlinkSync(path);
|
|
25
|
-
}
|
|
26
|
-
catch { /* a liveness reclaim already removed it */ } };
|
|
27
|
-
}
|
|
28
|
-
catch (e) {
|
|
29
|
-
if (e.code !== 'EEXIST')
|
|
30
|
-
throw e;
|
|
31
|
-
let owner = 0;
|
|
32
|
-
try {
|
|
33
|
-
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
34
|
-
}
|
|
35
|
-
catch { /* race with creator/releaser */ }
|
|
36
|
-
if (owner && owner !== process.pid) {
|
|
37
|
-
try {
|
|
38
|
-
process.kill(owner, 0);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
try {
|
|
42
|
-
unlinkSync(path);
|
|
43
|
-
}
|
|
44
|
-
catch { /* race */ }
|
|
45
|
-
;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
// A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
|
|
50
|
-
// and the retry sweep will come back. Declining is not a lost message.
|
|
51
|
-
if (Date.now() >= deadline)
|
|
52
|
-
return null;
|
|
53
|
-
await pause(25);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
function read(id) {
|
|
58
|
-
try {
|
|
59
|
-
const raw = JSON.parse(readFileSync(queuePath(id), 'utf8'));
|
|
60
|
-
if (!Array.isArray(raw))
|
|
61
|
-
return [];
|
|
62
|
-
return raw.filter((m) => !!m && typeof m === 'object'
|
|
63
|
-
&& typeof m.mid === 'string'
|
|
64
|
-
&& typeof m.text === 'string'
|
|
65
|
-
&& (m.dispatch === undefined
|
|
66
|
-
|| (m.dispatch?.operation === 'merge'
|
|
67
|
-
&& typeof m.dispatch?.requestDigest === 'string')));
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return [];
|
|
71
|
-
} // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
72
|
-
}
|
|
73
|
-
// Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
|
|
74
|
-
// one existsSync on the sweep's hot path.
|
|
75
|
-
function write(id, msgs) {
|
|
76
|
-
const path = queuePath(id);
|
|
77
|
-
if (!msgs.length) {
|
|
78
|
-
try {
|
|
79
|
-
unlinkSync(path);
|
|
80
|
-
}
|
|
81
|
-
catch { /* already gone */ }
|
|
82
|
-
;
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
mkdirSync(sessionStoreDir(id), { recursive: true });
|
|
86
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
87
|
-
writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n');
|
|
88
|
-
renameSync(tmp, path);
|
|
89
|
-
}
|
|
90
|
-
// A closed sender may have left debt in many other sessions' queues. The marker is deliberately outside its
|
|
91
|
-
// own store (which close removes) and is checked under the sender's record lock by dispatch: a close cannot
|
|
92
|
-
// return while an old process can still append, and a later sweep cannot hand over what it sees here.
|
|
93
|
-
export function revokeSenderDelivery(id) {
|
|
94
|
-
mkdirSync(revokedSenderRoot(), { recursive: true });
|
|
95
|
-
writeFileSync(revokedSenderPath(id), `${id}\n`);
|
|
96
|
-
}
|
|
97
|
-
export const senderDeliveryRevoked = (id) => existsSync(revokedSenderPath(id));
|
|
98
|
-
export function pendingSnapshot(id) { return read(id); }
|
|
99
|
-
// These two writes require the target's delivery lock. They are the queue half of a larger transaction
|
|
100
|
-
// (currently reparent), which must be able to restore the exact previous debt if a later record write fails.
|
|
101
|
-
export function replacePendingWhileLocked(id, msgs) { write(id, msgs); }
|
|
102
|
-
export function revokePendingFromWhileLocked(id, sender) {
|
|
103
|
-
const current = read(id);
|
|
104
|
-
const next = current.filter((msg) => msg.from !== sender);
|
|
105
|
-
write(id, next);
|
|
106
|
-
return current.length - next.length;
|
|
107
|
-
}
|
|
108
|
-
// The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
|
|
109
|
-
// both, and the record is written first, so delivery is never unrecorded. A keyed dispatch also carries its
|
|
110
|
-
// exact delivery bytes in the timeline receipt, letting its retry restore a queue write lost to a crash.
|
|
111
|
-
export function enqueue(id, msg) {
|
|
112
|
-
write(id, [...read(id), msg]);
|
|
113
|
-
}
|
|
114
|
-
// Caller holds this target's delivery lock. A retry may reach this after either side of the receipt->queue
|
|
115
|
-
// crash boundary; exact mid identity makes reconstruction idempotent without inspecting message text.
|
|
116
|
-
export function ensurePendingWhileLocked(id, msg) {
|
|
117
|
-
const current = read(id);
|
|
118
|
-
if (current.some((pending) => pending.mid === msg.mid))
|
|
119
|
-
return false;
|
|
120
|
-
write(id, [...current, msg]);
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
export const pendingMessages = (id) => read(id);
|
|
124
|
-
export const owesDelivery = (id) => existsSync(queuePath(id));
|
|
125
|
-
// Record transitions and queue mutations take locks in the same direction: record locks first, then these
|
|
126
|
-
// target queue locks. A batch reparent needs all child queues held at once so its pointer/watch/debt change
|
|
127
|
-
// either commits together or restores together.
|
|
128
|
-
export async function withDeliveryLocks(rawIds, body, index = 0, ids = [...new Set(rawIds)].sort()) {
|
|
129
|
-
if (index >= ids.length)
|
|
130
|
-
return body();
|
|
131
|
-
const release = await acquire(ids[index], 30_000);
|
|
132
|
-
if (!release)
|
|
133
|
-
throw new Error(`delivery queue ${ids[index]}: timed out waiting for transaction lock`);
|
|
134
|
-
try {
|
|
135
|
-
return await withDeliveryLocks(ids, body, index + 1, ids);
|
|
136
|
-
}
|
|
137
|
-
finally {
|
|
138
|
-
release();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
142
|
-
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
143
|
-
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
144
|
-
// one. Returns how many were handed over and how many are still owed.
|
|
145
|
-
export async function drain(id, insert, timeoutMs = 5_000) {
|
|
146
|
-
const release = await acquire(id, timeoutMs);
|
|
147
|
-
if (!release)
|
|
148
|
-
return { delivered: 0, remaining: read(id).length };
|
|
149
|
-
let delivered = 0;
|
|
150
|
-
try {
|
|
151
|
-
for (;;) {
|
|
152
|
-
const queued = read(id);
|
|
153
|
-
if (!queued.length)
|
|
154
|
-
return { delivered, remaining: 0 };
|
|
155
|
-
if (queued[0].from && senderDeliveryRevoked(queued[0].from)) {
|
|
156
|
-
// Closing a sender voids its undelivered output, not the recipient's immutable conversation history.
|
|
157
|
-
// Drop a revoked head and continue so it cannot permanently block the messages behind it.
|
|
158
|
-
write(id, read(id).filter((m) => m.mid !== queued[0].mid));
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
let ok = false;
|
|
162
|
-
try {
|
|
163
|
-
ok = await insert(queued[0]);
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
ok = false;
|
|
167
|
-
}
|
|
168
|
-
if (!ok)
|
|
169
|
-
return { delivered, remaining: queued.length };
|
|
170
|
-
// Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
|
|
171
|
-
// stale snapshot minus the head would silently drop it.
|
|
172
|
-
write(id, read(id).filter((m) => m.mid !== queued[0].mid));
|
|
173
|
-
delivered++;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
release();
|
|
178
|
-
}
|
|
179
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { TimelineEvent } from './session-timeline.js';
|
|
2
|
-
export type Cursors = {
|
|
3
|
-
version: 1;
|
|
4
|
-
follows: Record<string, number>;
|
|
5
|
-
};
|
|
6
|
-
export declare function readCursors(id: string): Cursors;
|
|
7
|
-
export declare const followCursor: (id: string, target: string) => number | null;
|
|
8
|
-
export declare function advanceFollow(id: string, target: string, to: number): void;
|
|
9
|
-
export declare const followedSessions: (id: string) => string[];
|
|
10
|
-
export declare function unreadSince(events: TimelineEvent[], from: number): {
|
|
11
|
-
events: TimelineEvent[];
|
|
12
|
-
at: number[];
|
|
13
|
-
next: number;
|
|
14
|
-
};
|
package/dist/session-cursors.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { sessionArtifactPath, sessionStoreDir } from '@spexcode/spec-core';
|
|
4
|
-
const cursorsPath = (id) => sessionArtifactPath(id, 'cursors.json');
|
|
5
|
-
const at = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
6
|
-
// Never throws: a missing, empty, or unparseable file reads as "nothing consumed", because re-showing a
|
|
7
|
-
// message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
|
|
8
|
-
// store dir is gone are dropped here — expiry is this read, and the next write persists it.
|
|
9
|
-
export function readCursors(id) {
|
|
10
|
-
let raw = null;
|
|
11
|
-
try {
|
|
12
|
-
raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8'));
|
|
13
|
-
}
|
|
14
|
-
catch { /* no cursors yet */ }
|
|
15
|
-
const follows = {};
|
|
16
|
-
const stored = raw?.follows;
|
|
17
|
-
if (stored && typeof stored === 'object') {
|
|
18
|
-
for (const [target, pos] of Object.entries(stored)) {
|
|
19
|
-
if (!target || !existsSync(sessionStoreDir(target)))
|
|
20
|
-
continue;
|
|
21
|
-
follows[target] = at(pos);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return { version: 1, follows };
|
|
25
|
-
}
|
|
26
|
-
// Written whole and atomically, one field per line — the same shape as the session record, so a position stays
|
|
27
|
-
// readable by an exact whole-line match where a value regex would not be.
|
|
28
|
-
function writeCursors(id, cursors) {
|
|
29
|
-
const dir = sessionStoreDir(id);
|
|
30
|
-
mkdirSync(dir, { recursive: true });
|
|
31
|
-
const tmp = join(dir, `.cursors.json.${process.pid}.tmp`);
|
|
32
|
-
writeFileSync(tmp, JSON.stringify(cursors, null, 2) + '\n');
|
|
33
|
-
renameSync(tmp, cursorsPath(id));
|
|
34
|
-
}
|
|
35
|
-
export const followCursor = (id, target) => {
|
|
36
|
-
const stored = readCursors(id).follows[target];
|
|
37
|
-
return stored === undefined ? null : stored;
|
|
38
|
-
};
|
|
39
|
-
// Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
|
|
40
|
-
// Monotonic: a stale read can leave a position too low (an event read twice), never too high (one lost).
|
|
41
|
-
export function advanceFollow(id, target, to) {
|
|
42
|
-
const cursors = readCursors(id);
|
|
43
|
-
const stored = cursors.follows[target];
|
|
44
|
-
if (stored !== undefined && to <= stored)
|
|
45
|
-
return;
|
|
46
|
-
writeCursors(id, { ...cursors, follows: { ...cursors.follows, [target]: Math.max(0, Math.floor(to)) } });
|
|
47
|
-
}
|
|
48
|
-
export const followedSessions = (id) => Object.keys(readCursors(id).follows);
|
|
49
|
-
const sameStatus = (a, b) => a.kind === 'status' && b.kind === 'status' && a.status === b.status
|
|
50
|
-
&& (a.proposal ?? null) === (b.proposal ?? null) && (a.note ?? null) === (b.note ?? null);
|
|
51
|
-
// @@@ edges, not lines - what a reader has not yet seen, with repeated status lines dropped. X→X IS NOT A
|
|
52
|
-
// TRANSITION: the log is append-only and already holds runs of identical status lines from before the
|
|
53
|
-
// timeline observer was deleted (every stray `spex serve` process ran an fs.watch over the same store and
|
|
54
|
-
// re-recorded each real move, so ONE transition landed as up to 6 lines within ~200ms). Those bytes are
|
|
55
|
-
// history and stay. Any consumer that decides "did it move?" must therefore compare VALUES, never adjacency,
|
|
56
|
-
// and must compare against the last status the reader ALREADY saw — otherwise a duplicate straddling the
|
|
57
|
-
// cursor boundary reads as a fresh move on the very next tick. `next` is where the cursor goes after consuming
|
|
58
|
-
// the WHOLE slice: the full length, because a dropped duplicate is still consumed. `at[i]` is the absolute
|
|
59
|
-
// index of `events[i]`, which is what lets a reader that STOPS on one event ([[session-follow]]'s take-one
|
|
60
|
-
// wait) advance to exactly `at[i] + 1` and leave the lines behind it unread for the next reader.
|
|
61
|
-
export function unreadSince(events, from) {
|
|
62
|
-
const start = Math.min(Math.max(0, Math.floor(from)), events.length);
|
|
63
|
-
let prev = null;
|
|
64
|
-
for (let i = start - 1; i >= 0; i--)
|
|
65
|
-
if (events[i].kind === 'status') {
|
|
66
|
-
prev = events[i];
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
const out = [];
|
|
70
|
-
const at = [];
|
|
71
|
-
for (let i = start; i < events.length; i++) {
|
|
72
|
-
const e = events[i];
|
|
73
|
-
if (e.kind === 'status') {
|
|
74
|
-
if (prev && sameStatus(prev, e))
|
|
75
|
-
continue;
|
|
76
|
-
prev = e;
|
|
77
|
-
}
|
|
78
|
-
out.push(e);
|
|
79
|
-
at.push(i);
|
|
80
|
-
}
|
|
81
|
-
return { events: out, at, next: events.length };
|
|
82
|
-
}
|