@ccmsg/cli 0.7.0 → 0.8.1
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/package.json +2 -2
- package/src/instance/instance.ts +17 -4
- package/src/mesh/index.ts +1 -0
- package/src/mesh/instances.ts +76 -0
- package/src/mesh/mesh.ts +1 -1
- package/src/mesh/relay.ts +90 -42
- package/src/messaging/delivery.ts +17 -7
- package/src/sessions/classify.ts +12 -0
- package/src/sessions/dump.ts +4 -1
- package/src/sessions/items.ts +1 -1
- package/src/sessions/last-live.ts +22 -5
- package/src/sessions/registry.ts +102 -55
- package/src/topics/elements.ts +0 -0
- package/src/topics/index.ts +1 -0
- package/src/transcript/files.ts +38 -1
- package/src/transcript/items/classify.ts +26 -16
- package/src/upstream/gateway.ts +4 -0
- package/src/upstream/requests.ts +40 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.20.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/instance/instance.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
Transport,
|
|
63
63
|
type UpgradeDecision,
|
|
64
64
|
} from "../transport/index.ts";
|
|
65
|
-
import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
|
|
65
|
+
import { Instances, Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
|
|
66
66
|
import {
|
|
67
67
|
Gateway,
|
|
68
68
|
gatewayCapabilities,
|
|
@@ -317,6 +317,7 @@ export class Instance {
|
|
|
317
317
|
readonly #transport = new Transport();
|
|
318
318
|
readonly #topics: Topics;
|
|
319
319
|
readonly #sessions: Sessions;
|
|
320
|
+
readonly #instances: Instances;
|
|
320
321
|
readonly #status: SessionStatus;
|
|
321
322
|
readonly #transcripts: Transcripts;
|
|
322
323
|
readonly #gateway: Gateway;
|
|
@@ -401,10 +402,10 @@ export class Instance {
|
|
|
401
402
|
const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
|
|
402
403
|
if (Array.isArray(stated)) this.#auth.merge(stated);
|
|
403
404
|
},
|
|
404
|
-
//
|
|
405
|
-
//
|
|
405
|
+
// The mesh view is this instance's own, so the topic that carries it is
|
|
406
|
+
// restated when that view moves (§7.5).
|
|
406
407
|
changed: () => {
|
|
407
|
-
this.#
|
|
408
|
+
this.#instances.refresh();
|
|
408
409
|
this.#linkMoved();
|
|
409
410
|
},
|
|
410
411
|
});
|
|
@@ -420,6 +421,9 @@ export class Instance {
|
|
|
420
421
|
onActivity: () => {
|
|
421
422
|
this.#sessions.refresh();
|
|
422
423
|
},
|
|
424
|
+
onMoved: (sid) => {
|
|
425
|
+
this.#sessions.gatewayMoved(sid);
|
|
426
|
+
},
|
|
423
427
|
log: (msg, fields) => {
|
|
424
428
|
this.log.write(msg, fields);
|
|
425
429
|
},
|
|
@@ -532,6 +536,15 @@ export class Instance {
|
|
|
532
536
|
publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
|
|
533
537
|
});
|
|
534
538
|
|
|
539
|
+
this.#instances = new Instances({
|
|
540
|
+
self: this.self,
|
|
541
|
+
...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self, mesh: this.#mesh }),
|
|
542
|
+
publish: (topic, data) => {
|
|
543
|
+
this.#topics.publish(topic, data);
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
this.#topics.attach("instances", this.#instances);
|
|
535
548
|
this.#topics.attach("peers", this.#sessions);
|
|
536
549
|
this.#topics.attach("agents", this.#sessions);
|
|
537
550
|
this.#topics.attach("inbox", this.#delivery);
|
package/src/mesh/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import type { Endpoint, InstanceId, InstanceInfo } from "@ccmsg/protocol";
|
|
3
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
4
|
+
|
|
5
|
+
/** What the `instances` topic reads: the mesh as this instance sees it, which
|
|
6
|
+
* is the same view `hello` answers with. */
|
|
7
|
+
export interface MeshView {
|
|
8
|
+
instances(): InstanceInfo[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The cluster as one instance sees it, itself included.
|
|
12
|
+
*
|
|
13
|
+
* An instance with no mesh is a cluster of one and says so: it is reached at
|
|
14
|
+
* whatever it serves, which is a row without an endpoint when that is the unix
|
|
15
|
+
* socket alone rather than no row at all (contract, `InstanceInfo`).
|
|
16
|
+
*
|
|
17
|
+
* The same answer feeds `hello` and the topic, from here rather than from two
|
|
18
|
+
* places: a greeting and a subscription that disagreed about who is in the
|
|
19
|
+
* cluster would be one instance stating two views of itself. */
|
|
20
|
+
export function clusterView(
|
|
21
|
+
self: InstanceId,
|
|
22
|
+
endpoint: Endpoint | undefined,
|
|
23
|
+
mesh: MeshView | undefined,
|
|
24
|
+
): InstanceInfo[] {
|
|
25
|
+
return (
|
|
26
|
+
mesh?.instances() ?? [
|
|
27
|
+
{
|
|
28
|
+
id: self,
|
|
29
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
30
|
+
host: hostname(),
|
|
31
|
+
reachable: true,
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `instances` topic (§7.5).
|
|
38
|
+
*
|
|
39
|
+
* A link going down reaches a subscriber where it is already listening, rather
|
|
40
|
+
* than only on its next greeting. The value is whole per instance: `reachable`
|
|
41
|
+
* is one instance's reading of every link it has, taken together, and two
|
|
42
|
+
* instances may legitimately disagree about the same link — so a frame states
|
|
43
|
+
* one sender's view entire and leaves every other sender's alone.
|
|
44
|
+
*
|
|
45
|
+
* Nothing is started or stopped by a subscription. The view exists because the
|
|
46
|
+
* mesh exists, and reading it costs a walk over the configured peers (§6.3:
|
|
47
|
+
* what subscription drives is a watch, and there is none here). */
|
|
48
|
+
export class Instances implements UpstreamResource {
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly deps: {
|
|
51
|
+
readonly self: InstanceId;
|
|
52
|
+
readonly endpoint?: Endpoint;
|
|
53
|
+
readonly mesh?: MeshView;
|
|
54
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
55
|
+
},
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
view(): InstanceInfo[] {
|
|
59
|
+
return clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** State the view, which the mesh asks for whenever a link moves. A frame
|
|
63
|
+
* equal to the last one goes no further than the suppression every topic
|
|
64
|
+
* shares (M5), so restating it costs nothing when nothing moved. */
|
|
65
|
+
refresh(): void {
|
|
66
|
+
this.deps.publish("instances", { instances: this.view() });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
start(): void {}
|
|
70
|
+
|
|
71
|
+
stop(): void {}
|
|
72
|
+
|
|
73
|
+
snapshot(): readonly TopicValue[] {
|
|
74
|
+
return [{ instance: this.deps.self, data: { instances: this.view() } }];
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/mesh/mesh.ts
CHANGED
|
@@ -763,7 +763,7 @@ export class Mesh {
|
|
|
763
763
|
this.#host?.element(topic, instance as InstanceId, fields["data"]);
|
|
764
764
|
return true;
|
|
765
765
|
}
|
|
766
|
-
this.relay.accept(instance as InstanceId, topic, fields["data"]);
|
|
766
|
+
this.relay.accept(instance as InstanceId, topic, fields["data"], fields["snapshot"] === true);
|
|
767
767
|
return true;
|
|
768
768
|
}
|
|
769
769
|
|
package/src/mesh/relay.ts
CHANGED
|
@@ -1,40 +1,59 @@
|
|
|
1
|
+
import type { SessionRow } from "../topics/index.ts";
|
|
1
2
|
import {
|
|
2
|
-
type AgentInfo,
|
|
3
3
|
type InstanceId,
|
|
4
4
|
LAST_LIVE_RETENTION_MS,
|
|
5
|
-
type LastLiveSession,
|
|
6
|
-
type PeerInfo,
|
|
7
5
|
PLAIN_TOPICS,
|
|
8
6
|
type Sid,
|
|
9
7
|
TOPIC_ATTRIBUTES,
|
|
10
8
|
type Timestamp,
|
|
11
9
|
} from "@ccmsg/protocol";
|
|
12
|
-
import type
|
|
10
|
+
import { Elements, type TopicValue } from "../topics/index.ts";
|
|
11
|
+
|
|
12
|
+
/** The rows of sessions the whole cluster is seen through.
|
|
13
|
+
*
|
|
14
|
+
* They are `element`-granular, and an element topic is relayable only when its
|
|
15
|
+
* elements say whose they are: a row here names the instance that holds the
|
|
16
|
+
* session, so two instances' rows stand side by side under one topic name the
|
|
17
|
+
* way a whole value per instance does. `inbox` and `kv:<ns>` are elements of
|
|
18
|
+
* the same granularity and are not relayed, because their elements carry no
|
|
19
|
+
* such name — an `inbox` frame belongs to a session, not to an instance. */
|
|
20
|
+
const ROW_TOPICS: readonly string[] = ["peers", "agents"];
|
|
13
21
|
|
|
14
22
|
/** The topics a subscriber sees the whole cluster on.
|
|
15
23
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
(topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
|
|
25
|
-
);
|
|
24
|
+
* A per-instance whole is relayable by construction (§6.2): a frame replaces
|
|
25
|
+
* its own instance's entries and leaves every other instance's alone, so
|
|
26
|
+
* several instances can state the same topic name without colliding. The rows
|
|
27
|
+
* above are relayable for the same reason read one element at a time. */
|
|
28
|
+
export const CLUSTER_TOPICS: readonly string[] = [
|
|
29
|
+
...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
|
|
30
|
+
...ROW_TOPICS,
|
|
31
|
+
];
|
|
26
32
|
|
|
27
33
|
/** The one topic the mesh carries that the relay does not.
|
|
28
34
|
*
|
|
29
|
-
* It is `element`-granular
|
|
30
|
-
*
|
|
31
|
-
*
|
|
35
|
+
* It is `element`-granular and its elements are the instances' own, so it is
|
|
36
|
+
* asked for as the instance rather than on a person's behalf, and folded into
|
|
37
|
+
* the set this instance holds rather than held here (DR-0001 §2.6). */
|
|
32
38
|
export const AUTH_TOPIC = "auth_records";
|
|
33
39
|
|
|
34
40
|
export function isClusterTopic(topic: string): boolean {
|
|
35
41
|
return CLUSTER_TOPICS.includes(topic);
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** Whether what a frame of this topic carries is rows to be merged rather than
|
|
45
|
+
* a value to be replaced. */
|
|
46
|
+
function carriesRows(topic: string): boolean {
|
|
47
|
+
return ROW_TOPICS.includes(topic);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The rows of one frame, under the field each topic names them in. A frame
|
|
51
|
+
* that carries none is one there is nothing to merge from. */
|
|
52
|
+
function rowsOf(topic: string, data: unknown): readonly SessionRow[] {
|
|
53
|
+
const field = (data as Record<string, unknown> | undefined)?.[topic];
|
|
54
|
+
return Array.isArray(field) ? (field as SessionRow[]) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export interface RelayDeps {
|
|
39
58
|
/** Hand a relayed frame to this instance's own subscribers, under the
|
|
40
59
|
* instance that produced it (§7.4). */
|
|
@@ -70,19 +89,47 @@ export class Relay {
|
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
/** A frame a peer pushed on a topic this instance relays.
|
|
92
|
+
*
|
|
93
|
+
* `snapshot` marks the opening frame of a subscription, which carries the
|
|
94
|
+
* whole of what its instance holds rather than what changed.
|
|
73
95
|
*
|
|
74
96
|
* `instance` is the one that produced the value, which is not always the
|
|
75
97
|
* peer it arrived from: a mesh of three relays transitively, and the frame
|
|
76
98
|
* names its origin the whole way. Held under that origin, and passed on
|
|
77
99
|
* unchanged — recomputing it would put the same judgement in two places
|
|
78
100
|
* (§7.4). */
|
|
79
|
-
accept(instance: InstanceId, topic: string, data: unknown): void {
|
|
101
|
+
accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
|
|
80
102
|
if (!isClusterTopic(topic)) return;
|
|
81
103
|
this.#sweep();
|
|
82
104
|
const held = this.#held.get(instance) ?? new Map<string, unknown>();
|
|
83
105
|
this.#held.set(instance, held);
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
if (!carriesRows(topic)) {
|
|
107
|
+
held.set(topic, data);
|
|
108
|
+
this.deps.publish(topic, data, instance);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// A frame of rows says what changed, so what is held is the rows merged
|
|
112
|
+
// and what travels on is the part of it that said something. The same
|
|
113
|
+
// comparison the mechanism makes of a whole value, made of one element
|
|
114
|
+
// (M5) — and a frame left with no rows is not passed on at all, which is
|
|
115
|
+
// what stops a peer's restatement from becoming a frame for every local
|
|
116
|
+
// subscriber.
|
|
117
|
+
// An opening frame is the whole of what its instance holds, so it is taken
|
|
118
|
+
// as the list restated rather than as changes folded in: a row the peer no
|
|
119
|
+
// longer has is gone from it and from nowhere else, and merging would leave
|
|
120
|
+
// it here forever. What comes of that is the same kind of answer either
|
|
121
|
+
// way — the rows that told this instance something, removals included.
|
|
122
|
+
const rows = this.#rows(held, topic);
|
|
123
|
+
const stated = rowsOf(topic, data);
|
|
124
|
+
const news = snapshot ? rows.diff(stated) : rows.merge(stated);
|
|
125
|
+
if (news.length > 0) this.deps.publish(topic, { [topic]: news }, instance);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The rows one instance has stated on a topic, made the first time it does. */
|
|
129
|
+
#rows(held: Map<string, unknown>, topic: string): Elements {
|
|
130
|
+
const rows = (held.get(topic) as Elements | undefined) ?? new Elements();
|
|
131
|
+
held.set(topic, rows);
|
|
132
|
+
return rows;
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
/** The link to this instance is gone. What it said is kept and marked,
|
|
@@ -113,7 +160,16 @@ export class Relay {
|
|
|
113
160
|
const values: TopicValue[] = [];
|
|
114
161
|
for (const [instance, held] of this.#held) {
|
|
115
162
|
const data = held.get(topic);
|
|
116
|
-
if (data
|
|
163
|
+
if (data === undefined) continue;
|
|
164
|
+
// A topic of rows is held merged, and the opening frame of a topic is
|
|
165
|
+
// its whole value — so what a fresh subscriber is handed is every row
|
|
166
|
+
// that instance has stated, in one frame of the same shape as the ones
|
|
167
|
+
// that follow it.
|
|
168
|
+
values.push(
|
|
169
|
+
carriesRows(topic)
|
|
170
|
+
? { instance, data: { [topic]: (data as Elements).rows() } }
|
|
171
|
+
: { instance, data },
|
|
172
|
+
);
|
|
117
173
|
}
|
|
118
174
|
return values;
|
|
119
175
|
}
|
|
@@ -121,29 +177,21 @@ export class Relay {
|
|
|
121
177
|
/** Which instance a session belongs to, read from the cluster values the
|
|
122
178
|
* peers stated (§7.3).
|
|
123
179
|
*
|
|
124
|
-
* `peers` names every session an instance
|
|
125
|
-
*
|
|
126
|
-
* has no row there
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* instance still points at the session's own. */
|
|
180
|
+
* `peers` names every session an instance holds, connected and lost alike,
|
|
181
|
+
* and is checked first. A session whose greeting has not reached its
|
|
182
|
+
* instance yet has no row there while the harness may already know of it, so
|
|
183
|
+
* `agents` is checked next. Every row names its own instance rather than the
|
|
184
|
+
* one that relayed it, so a row that travelled through a third instance
|
|
185
|
+
* still points at the session's own. */
|
|
131
186
|
owner(sid: Sid): InstanceId | undefined {
|
|
132
187
|
this.#sweep();
|
|
133
|
-
for (const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const row = value?.agents?.find((agent) => agent.sid === sid);
|
|
141
|
-
if (row !== undefined) return row.instance;
|
|
142
|
-
}
|
|
143
|
-
for (const held of this.#held.values()) {
|
|
144
|
-
const value = held.get("peers") as { last_live?: LastLiveSession[] } | undefined;
|
|
145
|
-
const row = value?.last_live?.find((session) => session.sid === sid);
|
|
146
|
-
if (row !== undefined) return row.instance;
|
|
188
|
+
for (const topic of ROW_TOPICS) {
|
|
189
|
+
for (const held of this.#held.values()) {
|
|
190
|
+
const row = (held.get(topic) as Elements | undefined)
|
|
191
|
+
?.rows()
|
|
192
|
+
.find((held) => held.sid === sid);
|
|
193
|
+
if (row !== undefined) return row.instance;
|
|
194
|
+
}
|
|
147
195
|
}
|
|
148
196
|
return undefined;
|
|
149
197
|
}
|
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
CandidateSession,
|
|
4
4
|
InboxMessage,
|
|
5
5
|
InstanceId,
|
|
6
|
-
LastLiveSession,
|
|
7
6
|
MessageSendArgs,
|
|
8
7
|
MessageSendResult,
|
|
9
8
|
Mid,
|
|
@@ -15,6 +14,7 @@ import type {
|
|
|
15
14
|
UndeliveredReason,
|
|
16
15
|
} from "@ccmsg/protocol";
|
|
17
16
|
import { USER_SENDER } from "@ccmsg/protocol";
|
|
17
|
+
import { isLive } from "../sessions/classify.ts";
|
|
18
18
|
import {
|
|
19
19
|
type DispatchResult,
|
|
20
20
|
type HandlerInput,
|
|
@@ -35,7 +35,7 @@ const INBOX = "inbox";
|
|
|
35
35
|
* the reasons of §4.2 from growing a source per reason. */
|
|
36
36
|
export interface SessionLookup {
|
|
37
37
|
classify(sid: Sid): SessionState | undefined;
|
|
38
|
-
|
|
38
|
+
peerRows(): PeerInfo[];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** The rest of the cluster, for a message addressed outside this instance.
|
|
@@ -290,17 +290,27 @@ export class Delivery implements UpstreamResource {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/** Sessions live now in the repository the addressee belongs to (§4.2).
|
|
293
|
+
*
|
|
294
|
+
* The rows are one list of sessions, connected and lost alike, so which of
|
|
295
|
+
* them can be written to is the classification — asked of the domain, the
|
|
296
|
+
* same way the addressee's own reason was, rather than read off a field of
|
|
297
|
+
* the row (M1).
|
|
293
298
|
*
|
|
294
299
|
* The repository is `repo_root` as the session named it. A session that named
|
|
295
300
|
* none is left out rather than matched on something derived from its `cwd`:
|
|
296
301
|
* no primary source states that derivation, and the sessions domain does not
|
|
297
302
|
* make one up either. */
|
|
298
303
|
#candidates(to: Sid): CandidateSession[] {
|
|
299
|
-
const
|
|
300
|
-
const root =
|
|
304
|
+
const rows = this.deps.sessions.peerRows();
|
|
305
|
+
const root = rows.find((row) => row.sid === to)?.repo_root;
|
|
301
306
|
if (root === undefined) return [];
|
|
302
|
-
return
|
|
303
|
-
.filter(
|
|
307
|
+
return rows
|
|
308
|
+
.filter(
|
|
309
|
+
(peer) =>
|
|
310
|
+
peer.sid !== to &&
|
|
311
|
+
peer.repo_root === root &&
|
|
312
|
+
isLive({ state: this.deps.sessions.classify(peer.sid) }),
|
|
313
|
+
)
|
|
304
314
|
.map((peer) => ({
|
|
305
315
|
sid: peer.sid,
|
|
306
316
|
...(peer.ws === "" ? {} : { ws: peer.ws }),
|
|
@@ -374,7 +384,7 @@ function callerOf(input: HandlerInput): CallerIdentity | undefined {
|
|
|
374
384
|
* notification's subject are the same session seen from two ops, and a session
|
|
375
385
|
* shown one way there and another way here would read as two. */
|
|
376
386
|
export function sessionLabel(sessions: SessionLookup, sid: Sid): string {
|
|
377
|
-
const peer = sessions.
|
|
387
|
+
const peer = sessions.peerRows().find((row) => row.sid === sid);
|
|
378
388
|
if (peer === undefined) return sid;
|
|
379
389
|
const where = [peer.repo, peer.ws].filter((part) => part !== "").join("/");
|
|
380
390
|
return where === "" ? sid : where;
|
package/src/sessions/classify.ts
CHANGED
|
@@ -64,3 +64,15 @@ export function classify(
|
|
|
64
64
|
if (inputs.last_live === undefined) return undefined;
|
|
65
65
|
return inputs.last_live.stopped_at === undefined ? "disappeared" : "paused";
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/** Whether a classification is one of the connected ones, which is what a row
|
|
69
|
+
* has to be for anything here to reach it.
|
|
70
|
+
*
|
|
71
|
+
* Read off the classification rather than restated: the two sections the
|
|
72
|
+
* vocabulary is divided into are the contract's own, and a second list of
|
|
73
|
+
* which words mean "still there" would be a second place for the rule to drift
|
|
74
|
+
* in (M1). A row that states none is one no section holds, which is not a
|
|
75
|
+
* session anything can be handed to. */
|
|
76
|
+
export function isLive(row: { readonly state?: SessionState }): boolean {
|
|
77
|
+
return row.state !== undefined && row.state !== "paused" && row.state !== "disappeared";
|
|
78
|
+
}
|
package/src/sessions/dump.ts
CHANGED
|
@@ -75,7 +75,10 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
75
75
|
},
|
|
76
76
|
deps.presets,
|
|
77
77
|
);
|
|
78
|
-
const { items, entries } = select(
|
|
78
|
+
const { items, entries } = select(
|
|
79
|
+
within(classify(located(text), deps.files.subjectOf(file)), args),
|
|
80
|
+
keep,
|
|
81
|
+
);
|
|
79
82
|
const ids = ledger(items);
|
|
80
83
|
const written_at = Date.now();
|
|
81
84
|
// The file repeats what it was asked for. A dump outlives the request that
|
package/src/sessions/items.ts
CHANGED
|
@@ -68,7 +68,7 @@ export function itemsRead(
|
|
|
68
68
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
69
69
|
}
|
|
70
70
|
const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
|
|
71
|
-
const { items } = select(within(classify(located(text)), args), keep);
|
|
71
|
+
const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
|
|
72
72
|
const page = paged(items, args.limit, backwards(args));
|
|
73
73
|
return {
|
|
74
74
|
items: page.items,
|
|
@@ -3,22 +3,39 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
LAST_LIVE_RETENTION_MS,
|
|
5
5
|
type InstanceId,
|
|
6
|
-
type
|
|
6
|
+
type PeerInfo,
|
|
7
7
|
type Sid,
|
|
8
8
|
type Timestamp,
|
|
9
9
|
} from "@ccmsg/protocol";
|
|
10
10
|
|
|
11
11
|
export const LAST_LIVE_FILE = "last-live.json";
|
|
12
12
|
|
|
13
|
-
/** What is stored per session: the
|
|
14
|
-
*
|
|
13
|
+
/** What is stored per session: the observations a lost session's row is built
|
|
14
|
+
* from, and nothing the row derives or a connection supplies.
|
|
15
15
|
*
|
|
16
16
|
* `state` is left out on purpose (M4). It follows from `stopped_at` and from
|
|
17
17
|
* whether the session is live again, both of which are known when the list is
|
|
18
18
|
* read, so storing it would be storing a conclusion that can go stale on disk.
|
|
19
19
|
* `pinned` is left out because no pin is held anywhere yet; when one is, it
|
|
20
|
-
* belongs to the session rather than to this list.
|
|
21
|
-
|
|
20
|
+
* belongs to the session rather than to this list. The connection fields go
|
|
21
|
+
* with the connection there is none of.
|
|
22
|
+
*
|
|
23
|
+
* `last_seen_at` is required here while the row states it optionally: a row
|
|
24
|
+
* this store holds is by definition one this instance has lost, and when it
|
|
25
|
+
* last saw it is what the retention window is measured from. */
|
|
26
|
+
export type StoredEntry = Omit<
|
|
27
|
+
PeerInfo,
|
|
28
|
+
| "state"
|
|
29
|
+
| "pinned"
|
|
30
|
+
| "last_activity_at"
|
|
31
|
+
| "last_user_input_at"
|
|
32
|
+
| "gateway_active_at"
|
|
33
|
+
| "send_message"
|
|
34
|
+
| "client_version"
|
|
35
|
+
| "protocol_version"
|
|
36
|
+
| "stale_client"
|
|
37
|
+
| "last_seen_at"
|
|
38
|
+
> & { last_seen_at: Timestamp };
|
|
22
39
|
|
|
23
40
|
interface Document {
|
|
24
41
|
version: number;
|
package/src/sessions/registry.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { hostname } from "node:os";
|
|
3
2
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
3
|
import {
|
|
5
4
|
type AgentInfo,
|
|
@@ -7,9 +6,10 @@ import {
|
|
|
7
6
|
type HelloArgs,
|
|
8
7
|
type HelloResult,
|
|
9
8
|
type Endpoint,
|
|
9
|
+
type AgentElement,
|
|
10
10
|
type InstanceId,
|
|
11
11
|
type InstanceInfo,
|
|
12
|
-
type
|
|
12
|
+
type PeerElement,
|
|
13
13
|
type PeerInfo,
|
|
14
14
|
PROTOCOL_VERSION,
|
|
15
15
|
type SessionState,
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
21
21
|
import { within } from "../files/index.ts";
|
|
22
22
|
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
23
|
+
import { clusterView } from "../mesh/instances.ts";
|
|
23
24
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
24
|
-
import type
|
|
25
|
+
import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
25
26
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
26
27
|
import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
|
|
27
28
|
import { LastLiveStore, type StoredEntry } from "./last-live.ts";
|
|
@@ -211,6 +212,16 @@ export class Sessions implements UpstreamResource {
|
|
|
211
212
|
* then carries on stays connected and keeps its declaration, which is spent
|
|
212
213
|
* whenever it does leave. */
|
|
213
214
|
readonly #stopping = new Map<Sid, Timestamp>();
|
|
215
|
+
/** What the last frame of each topic left every subscriber holding, kept
|
|
216
|
+
* whether or not anybody is subscribed.
|
|
217
|
+
*
|
|
218
|
+
* A frame carries a difference, and the difference is taken against what was
|
|
219
|
+
* sent — not against what the last listener happened to see. Which is also
|
|
220
|
+
* what makes it right for a subscriber that arrives after a spell of nobody
|
|
221
|
+
* listening: it is handed every row as a snapshot, and every frame after it
|
|
222
|
+
* says what changed since the last one went out. */
|
|
223
|
+
readonly #sentPeers = new Elements();
|
|
224
|
+
readonly #sentAgents = new Elements();
|
|
214
225
|
|
|
215
226
|
constructor(private readonly deps: SessionsDeps) {
|
|
216
227
|
this.#harness = ownSessions(
|
|
@@ -294,18 +305,9 @@ export class Sessions implements UpstreamResource {
|
|
|
294
305
|
protocol_version: PROTOCOL_VERSION,
|
|
295
306
|
instance: this.deps.self,
|
|
296
307
|
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
// `InstanceInfo`).
|
|
301
|
-
instances: this.deps.mesh?.instances() ?? [
|
|
302
|
-
{
|
|
303
|
-
id: this.deps.self,
|
|
304
|
-
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
305
|
-
host: hostname(),
|
|
306
|
-
reachable: true,
|
|
307
|
-
},
|
|
308
|
-
],
|
|
308
|
+
// The same view the `instances` topic carries, worked out in one place
|
|
309
|
+
// so a greeting and a subscription cannot state two different clusters.
|
|
310
|
+
instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
|
|
309
311
|
capabilities: [...this.deps.capabilities],
|
|
310
312
|
version: this.deps.version,
|
|
311
313
|
started_at: this.deps.startedAt,
|
|
@@ -483,9 +485,21 @@ export class Sessions implements UpstreamResource {
|
|
|
483
485
|
if (this.#wanted.size === 0) this.#harness.stop();
|
|
484
486
|
}
|
|
485
487
|
|
|
488
|
+
/** What a fresh subscriber is handed: every row, connected and lost alike.
|
|
489
|
+
*
|
|
490
|
+
* A frame of either topic carries the rows that changed, so the opening one
|
|
491
|
+
* has to carry all of them — it is the only frame that states the rows a
|
|
492
|
+
* subscriber was not there for. Stating them is also what the difference
|
|
493
|
+
* after it is taken against: these rows are what the subscriber now holds,
|
|
494
|
+
* and they are the same rows every earlier subscriber was brought up to by
|
|
495
|
+
* the frames it has had. */
|
|
486
496
|
snapshot(topic: string): readonly TopicValue[] {
|
|
497
|
+
const now = Date.now();
|
|
487
498
|
const own = this.#own();
|
|
488
|
-
const data =
|
|
499
|
+
const data =
|
|
500
|
+
topic === "agents"
|
|
501
|
+
? { agents: this.#sentAgents.stated(this.agentRows(own)), polled_at: now }
|
|
502
|
+
: { peers: this.#sentPeers.stated(this.peerRows(now, own)) };
|
|
489
503
|
return [{ instance: this.deps.self, data }];
|
|
490
504
|
}
|
|
491
505
|
|
|
@@ -495,9 +509,11 @@ export class Sessions implements UpstreamResource {
|
|
|
495
509
|
return this.#harness.running;
|
|
496
510
|
}
|
|
497
511
|
|
|
498
|
-
/**
|
|
499
|
-
* instance last saw it.
|
|
500
|
-
*
|
|
512
|
+
/** Every row `peers` states: what is live now, and what was live when this
|
|
513
|
+
* instance last saw it. One kind of row rather than two lists, because
|
|
514
|
+
* coming back and going quiet are the same row changing its `state` — a
|
|
515
|
+
* client that held two lists would have to move an entry between them to
|
|
516
|
+
* follow one field.
|
|
501
517
|
*
|
|
502
518
|
* Live is not the same as connected (§5.2). A session the harness names is
|
|
503
519
|
* live whether or not it ever greeted us, and it has to be on this list for
|
|
@@ -510,41 +526,65 @@ export class Sessions implements UpstreamResource {
|
|
|
510
526
|
* — this instance is one that classifies, so it says so on every row rather
|
|
511
527
|
* than on the rows it happens to have an answer for.
|
|
512
528
|
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
529
|
+
* A lost session is a row here rather than a list of its own: its entry in
|
|
530
|
+
* the store holds what was observed, and the two fields a row derives —
|
|
531
|
+
* where it stands now, and whether it is pinned — are worked out at read
|
|
532
|
+
* time (M4). */
|
|
533
|
+
peerRows(now: Timestamp = Date.now(), own: Own = this.#own()): PeerInfo[] {
|
|
534
|
+
return [
|
|
535
|
+
...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
|
|
536
|
+
...[...own.present]
|
|
537
|
+
.filter((sid) => !this.#connected.has(sid))
|
|
538
|
+
.map((sid) => this.#unconnected(sid, now, own)),
|
|
539
|
+
...this.#lastLive.entries(now).map((entry) => this.#lost(entry, now, own)),
|
|
540
|
+
];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** One row of `peers`, for a producer that knows which session moved.
|
|
544
|
+
*
|
|
545
|
+
* The same three sources the list is built from, asked about one sid: a
|
|
546
|
+
* connection here, a session the harness names, an entry among the sessions
|
|
547
|
+
* this instance has lost. A sid none of them holds is one this instance has
|
|
548
|
+
* no row for, and it says so rather than inventing one. */
|
|
549
|
+
#peerRow(sid: Sid, now: Timestamp, own: Own): PeerInfo | undefined {
|
|
550
|
+
const held = this.#connected.get(sid);
|
|
551
|
+
if (held !== undefined) return this.#peer(held, now, own);
|
|
552
|
+
if (own.present.has(sid)) return this.#unconnected(sid, now, own);
|
|
553
|
+
const entry = this.#lastLive.get(sid);
|
|
554
|
+
return entry === undefined ? undefined : this.#lost(entry, now, own);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A session this instance has lost, as a row: what was observed of it,
|
|
558
|
+
* with the two fields a row derives worked out at read time (M4). */
|
|
559
|
+
#lost(entry: StoredEntry, now: Timestamp, own: Own): PeerInfo {
|
|
524
560
|
return {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
.filter((sid) => !this.#connected.has(sid))
|
|
529
|
-
.map((sid) => this.#unconnected(sid, now, own)),
|
|
530
|
-
],
|
|
531
|
-
last_live: this.#lastLive.entries(now).map((entry) => ({
|
|
532
|
-
...entry,
|
|
533
|
-
state: this.classify(entry.sid, now, own) ?? "disappeared",
|
|
534
|
-
pinned: this.#pinned(entry.sid),
|
|
535
|
-
})),
|
|
536
|
-
...(instances === undefined ? {} : { instances }),
|
|
561
|
+
...entry,
|
|
562
|
+
state: this.classify(entry.sid, now, own) ?? "disappeared",
|
|
563
|
+
pinned: this.#pinned(entry.sid),
|
|
537
564
|
};
|
|
538
565
|
}
|
|
539
566
|
|
|
540
|
-
/** The
|
|
567
|
+
/** The gateway saw inference for one session again (§5.1).
|
|
568
|
+
*
|
|
569
|
+
* What moved is one attribute of one row, so that row is what goes out. The
|
|
570
|
+
* sessions domain is not recomputed for it: which sessions there are has not
|
|
571
|
+
* changed, and the whole of that work would be spent to restate a clock.
|
|
541
572
|
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
|
|
547
|
-
|
|
573
|
+
* A sid this instance has no row for publishes nothing. The gateway sits
|
|
574
|
+
* above every config home and its events name only a session id, so one
|
|
575
|
+
* belonging to another config home must not become a row here — the same
|
|
576
|
+
* narrowing the row's own reading of the gateway makes. */
|
|
577
|
+
gatewayMoved(sid: Sid): void {
|
|
578
|
+
const now = Date.now();
|
|
579
|
+
const row = this.#peerRow(sid, now, this.#own());
|
|
580
|
+
if (row === undefined) return;
|
|
581
|
+
const peers = this.#sentPeers.diffRow(row) as PeerElement[];
|
|
582
|
+
if (peers.length > 0) this.deps.publish("peers", { peers });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Every row `agents` states: the harness's own view, as it stated it. */
|
|
586
|
+
agentRows(own: Own = this.#own()): AgentInfo[] {
|
|
587
|
+
return [...own.rows.values()];
|
|
548
588
|
}
|
|
549
589
|
|
|
550
590
|
/** Bind a session to this instance, and take what it says about itself. Its
|
|
@@ -596,11 +636,16 @@ export class Sessions implements UpstreamResource {
|
|
|
596
636
|
this.changed();
|
|
597
637
|
}
|
|
598
638
|
|
|
599
|
-
/** Recompute, record what stopped being live, and state both
|
|
639
|
+
/** Recompute, record what stopped being live, and state what moved on both
|
|
640
|
+
* topics.
|
|
600
641
|
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
642
|
+
* Both carry the rows that changed, so what is published is the difference
|
|
643
|
+
* against what was last published rather than the whole list: a session
|
|
644
|
+
* whose inference just ran is one row, and restating every row to say so
|
|
645
|
+
* would send a list to report one field. A recompute that found nothing
|
|
646
|
+
* different publishes nothing — the suppression every topic shares (M5) is
|
|
647
|
+
* whole-value and cannot drop a frame of elements, so the diff is where a
|
|
648
|
+
* repeat stops here. */
|
|
604
649
|
private changed(now: Timestamp = Date.now()): void {
|
|
605
650
|
const own = this.#own();
|
|
606
651
|
const live = this.#liveNow(now, own);
|
|
@@ -622,8 +667,10 @@ export class Sessions implements UpstreamResource {
|
|
|
622
667
|
}
|
|
623
668
|
this.#reclaim(live);
|
|
624
669
|
this.#live = live;
|
|
625
|
-
this.
|
|
626
|
-
this.deps.publish("
|
|
670
|
+
const peers = this.#sentPeers.diff(this.peerRows(now, own)) as PeerElement[];
|
|
671
|
+
if (peers.length > 0) this.deps.publish("peers", { peers });
|
|
672
|
+
const agents = this.#sentAgents.diff(this.agentRows(own)) as AgentElement[];
|
|
673
|
+
if (agents.length > 0) this.deps.publish("agents", { agents, polled_at: now });
|
|
627
674
|
this.deps.onChanged?.();
|
|
628
675
|
}
|
|
629
676
|
|
|
Binary file
|
package/src/topics/index.ts
CHANGED
package/src/transcript/files.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import type { Sid } from "@ccmsg/protocol";
|
|
3
|
+
import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
|
|
4
4
|
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
5
5
|
import { OpError } from "../dispatch/index.ts";
|
|
6
6
|
|
|
7
7
|
const SUFFIX = ".jsonl";
|
|
8
8
|
|
|
9
|
+
/** What the harness names an agent's transcript with, and what it calls the
|
|
10
|
+
* kind of task a teammate is. Both are its own words, read where they are
|
|
11
|
+
* written rather than mirrored anywhere. */
|
|
12
|
+
const AGENT_PREFIX = "agent-";
|
|
13
|
+
const TEAMMATE_TASK = "in_process_teammate";
|
|
14
|
+
|
|
9
15
|
/** Where one harness keeps transcripts under its config home, and how a file
|
|
10
16
|
* there says which session it belongs to (§3.8).
|
|
11
17
|
*
|
|
@@ -126,6 +132,37 @@ export class TranscriptFiles {
|
|
|
126
132
|
return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
/** Which standing a transcript was written from, which every item read out
|
|
136
|
+
* of it states (§3.6).
|
|
137
|
+
*
|
|
138
|
+
* The file itself does not say whether an agent was a teammate or an errand:
|
|
139
|
+
* both are marked as sidechains and both are briefed the same way. What says
|
|
140
|
+
* so is the harness's own note beside the file — the same note a teammate is
|
|
141
|
+
* found by name in — and `taskKind` on it is the harness stating which kind
|
|
142
|
+
* of task it started. An envelope in the opening brief looks like the same
|
|
143
|
+
* answer and is not one: it is text somebody wrote, and an errand handed a
|
|
144
|
+
* quoted message carries it too.
|
|
145
|
+
*
|
|
146
|
+
* A note that is missing or unreadable leaves the question unanswered, and
|
|
147
|
+
* the answer then is `sub`: an errand is the standing that claims the least —
|
|
148
|
+
* nothing goes on standing, nobody is addressed by name — so a teammate read
|
|
149
|
+
* as one loses a name it might have been drawn under, where the reverse would
|
|
150
|
+
* have a reader write back to something that is already gone. */
|
|
151
|
+
subjectOf(file: string): TranscriptSubject {
|
|
152
|
+
const name = basename(file);
|
|
153
|
+
if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
|
|
154
|
+
let note: unknown;
|
|
155
|
+
try {
|
|
156
|
+
note = JSON.parse(
|
|
157
|
+
readFileSync(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
|
|
158
|
+
);
|
|
159
|
+
} catch {
|
|
160
|
+
return "sub";
|
|
161
|
+
}
|
|
162
|
+
const kind = (note as { taskKind?: unknown } | null)?.taskKind;
|
|
163
|
+
return kind === TEAMMATE_TASK ? "team" : "sub";
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
/** A teammate's transcript, found by the name it is addressed by.
|
|
130
167
|
*
|
|
131
168
|
* The name a teammate carries in conversation is not its filename, so the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TranscriptSubject } from "@ccmsg/protocol";
|
|
1
2
|
import type { Item } from "./item.ts";
|
|
2
3
|
import {
|
|
3
4
|
count,
|
|
@@ -84,6 +85,7 @@ const OUTSTANDING_CALLS = 4096;
|
|
|
84
85
|
type Draft = Record<string, unknown> & {
|
|
85
86
|
id: string;
|
|
86
87
|
uuid: string;
|
|
88
|
+
subject: TranscriptSubject;
|
|
87
89
|
source: { offset: number; bytes: number };
|
|
88
90
|
type: string;
|
|
89
91
|
at: number;
|
|
@@ -96,8 +98,8 @@ type Draft = Record<string, unknown> & {
|
|
|
96
98
|
* apart the harness wrote them. Reading the whole file before any range is
|
|
97
99
|
* applied is what makes `parent_item` answerable: a result inside the range
|
|
98
100
|
* whose call fell before it still names the call. */
|
|
99
|
-
export function classify(records: Iterable<Located
|
|
100
|
-
const state = new Classification();
|
|
101
|
+
export function classify(records: Iterable<Located>, subject: TranscriptSubject = "main"): Item[] {
|
|
102
|
+
const state = new Classification(subject);
|
|
101
103
|
return state.readAll(records);
|
|
102
104
|
}
|
|
103
105
|
|
|
@@ -117,17 +119,24 @@ export class Classification {
|
|
|
117
119
|
#turn = 0;
|
|
118
120
|
/** The last slash command invoked, which is what its output belongs to. */
|
|
119
121
|
#slash: string | undefined;
|
|
120
|
-
/** Whose file this is, which
|
|
121
|
-
* line. A session's own transcript has a person
|
|
122
|
-
* agent has whoever started it, and calling that
|
|
123
|
-
* take a machine for a person.
|
|
122
|
+
/** Whose file this is, which every item states and which decides who is at
|
|
123
|
+
* the other end of a plain line. A session's own transcript has a person
|
|
124
|
+
* there; a file written for an agent has whoever started it, and calling that
|
|
125
|
+
* `user` would have a reader take a machine for a person.
|
|
124
126
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
|
|
127
|
+
* It is told rather than read out of the records, because what tells a
|
|
128
|
+
* teammate from an errand is not in the transcript at all: the harness states
|
|
129
|
+
* it beside the file, and whoever opened the file has already read that (§3.6).
|
|
130
|
+
*
|
|
131
|
+
* A record marked as a sidechain inside a file opened as a session's own says
|
|
132
|
+
* the file is an agent's after all, and the reading moves to `sub` — the
|
|
133
|
+
* standing that claims the least. It only ever narrows: a reading told which
|
|
134
|
+
* agent's file it has is not talked out of it by the records. */
|
|
135
|
+
#subject: TranscriptSubject;
|
|
136
|
+
|
|
137
|
+
constructor(subject: TranscriptSubject = "main") {
|
|
138
|
+
this.#subject = subject;
|
|
139
|
+
}
|
|
131
140
|
|
|
132
141
|
/** The records of one chunk as the items they were read as, oldest first.
|
|
133
142
|
*
|
|
@@ -158,7 +167,7 @@ export class Classification {
|
|
|
158
167
|
read(record: Row, source: { offset: number; bytes: number }): void {
|
|
159
168
|
const type = str(record["type"]);
|
|
160
169
|
if (type === undefined || NOT_ITEMS.has(type)) return;
|
|
161
|
-
if (record["isSidechain"] === true) this.#subject = "
|
|
170
|
+
if (record["isSidechain"] === true && this.#subject === "main") this.#subject = "sub";
|
|
162
171
|
// A record the harness wrote without an id of its own still happened, and
|
|
163
172
|
// an item is pointed at by the record it came from — so where the record
|
|
164
173
|
// stands in the file stands in for the id it lacks. The `@` says which of
|
|
@@ -174,6 +183,7 @@ export class Classification {
|
|
|
174
183
|
const draft: Draft = {
|
|
175
184
|
id: `${uuid}:${String(index)}`,
|
|
176
185
|
uuid,
|
|
186
|
+
subject: this.#subject,
|
|
177
187
|
source,
|
|
178
188
|
type: kind,
|
|
179
189
|
at,
|
|
@@ -255,7 +265,7 @@ export class Classification {
|
|
|
255
265
|
// them is the answer it was started for, and the ones before are what
|
|
256
266
|
// it hands back mid-flight. No call carries them, which is why
|
|
257
267
|
// `parent:out` is prose as well as a call.
|
|
258
|
-
const kind = this.#subject === "
|
|
268
|
+
const kind = this.#subject === "main" ? "message:user:out" : "message:parent:out";
|
|
259
269
|
if (said !== undefined && said !== "") make(kind, { text: said });
|
|
260
270
|
continue;
|
|
261
271
|
}
|
|
@@ -473,9 +483,9 @@ export class Classification {
|
|
|
473
483
|
// stands, being told what to do is not the same as being written to.
|
|
474
484
|
if (record["parentUuid"] === null) {
|
|
475
485
|
this.#turn += 1;
|
|
476
|
-
make(this.#subject === "
|
|
486
|
+
make(this.#subject === "main" ? "message:user:in" : "message:parent:in", {
|
|
477
487
|
text: said,
|
|
478
|
-
...(this.#subject === "
|
|
488
|
+
...(this.#subject === "main" ? {} : envelope(said)),
|
|
479
489
|
});
|
|
480
490
|
return;
|
|
481
491
|
}
|
package/src/upstream/gateway.ts
CHANGED
|
@@ -114,6 +114,9 @@ export interface GatewayDeps {
|
|
|
114
114
|
/** The gateway saw something happen for a session, which is an input of the
|
|
115
115
|
* sessions domain (§5.1) rather than of either topic. */
|
|
116
116
|
readonly onActivity?: () => void;
|
|
117
|
+
/** A session already known to be running was seen again: its clock moved,
|
|
118
|
+
* and the row that carries it is what says so. */
|
|
119
|
+
readonly onMoved?: (sid: Sid) => void;
|
|
117
120
|
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
118
121
|
/** Replaces the outward read in tests. */
|
|
119
122
|
readonly fetch?: typeof fetch;
|
|
@@ -139,6 +142,7 @@ export class Gateway {
|
|
|
139
142
|
self: deps.self,
|
|
140
143
|
publish: deps.publish,
|
|
141
144
|
...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
|
|
145
|
+
...(deps.onMoved === undefined ? {} : { onMoved: deps.onMoved }),
|
|
142
146
|
});
|
|
143
147
|
this.status =
|
|
144
148
|
deps.setup.statusUrl === undefined
|
package/src/upstream/requests.ts
CHANGED
|
@@ -14,10 +14,25 @@ export interface LlmRequestsDeps {
|
|
|
14
14
|
/** The one way a value reaches subscribers (§6.1). */
|
|
15
15
|
readonly publish: (topic: string, data: unknown) => void;
|
|
16
16
|
/** An event moved when a session was last seen running inference, which is
|
|
17
|
-
* an input of the sessions domain (§5.1) and not of this topic.
|
|
17
|
+
* an input of the sessions domain (§5.1) and not of this topic. Told when
|
|
18
|
+
* the window opened, which is the moment the classification can change. */
|
|
18
19
|
readonly onActivity?: () => void;
|
|
20
|
+
/** The same session seen again inside a window already open: one attribute
|
|
21
|
+
* of one row moved. Told apart from the above because what it asks for is
|
|
22
|
+
* that row restated rather than the whole domain recomputed. */
|
|
23
|
+
readonly onMoved?: (sid: Sid) => void;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** Which of a session's gateway facts moved.
|
|
27
|
+
*
|
|
28
|
+
* `live` is the moment the classification of §5.1 can change, because the
|
|
29
|
+
* window either opened or closed, and the sessions domain recomputes for it.
|
|
30
|
+
* `clock` is the same session seen again inside a window that was already open
|
|
31
|
+
* — the value of an attribute, not a section anything is in — so what it asks
|
|
32
|
+
* for is that one row restated. Both reach a subscriber; they differ in how
|
|
33
|
+
* much work is done to say so. */
|
|
34
|
+
type GatewayMove = "live" | "clock" | "none";
|
|
35
|
+
|
|
21
36
|
/** Series remembered at once. The prune below already holds this near the
|
|
22
37
|
* number active in the last cache window; the cap is what bounds a gateway
|
|
23
38
|
* whose clock runs ahead, whose events would otherwise never expire. */
|
|
@@ -69,7 +84,7 @@ export class LlmRequests implements UpstreamResource {
|
|
|
69
84
|
* near-ordered in practice, but a redelivery can put an older one after a
|
|
70
85
|
* newer, and a countdown must not walk backwards. */
|
|
71
86
|
record(info: LlmRequestObservation): void {
|
|
72
|
-
this.active(info.sid, info.received_at);
|
|
87
|
+
this.moved(info.sid, this.active(info.sid, info.received_at));
|
|
73
88
|
const key = seriesKey(info.sid, info.prefix);
|
|
74
89
|
const held = this.#series.get(key);
|
|
75
90
|
if (held !== undefined && held.info.received_at >= info.received_at) return;
|
|
@@ -91,7 +106,13 @@ export class LlmRequests implements UpstreamResource {
|
|
|
91
106
|
* the window belongs to the request that opened it — and only says the
|
|
92
107
|
* session was still running inference at that instant. */
|
|
93
108
|
note(sid: Sid, at: Timestamp): void {
|
|
94
|
-
|
|
109
|
+
this.moved(sid, this.active(sid, at));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tell whoever holds the row what this event moved for that session. */
|
|
113
|
+
private moved(sid: Sid, move: GatewayMove): void {
|
|
114
|
+
if (move === "live") this.deps.onActivity?.();
|
|
115
|
+
else if (move === "clock") this.deps.onMoved?.(sid);
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
/** When the gateway last saw inference for a session (§5.1). Undefined once
|
|
@@ -136,21 +157,31 @@ export class LlmRequests implements UpstreamResource {
|
|
|
136
157
|
|
|
137
158
|
private publish(): void {
|
|
138
159
|
this.deps.publish("llm_requests", this.entries());
|
|
139
|
-
this.deps.onActivity?.();
|
|
140
160
|
}
|
|
141
161
|
|
|
142
|
-
/** Note the session was seen, and say
|
|
143
|
-
|
|
162
|
+
/** Note the session was seen, and say what that moved.
|
|
163
|
+
*
|
|
164
|
+
* A session already inside its window moves its clock and nothing else, so
|
|
165
|
+
* the row it lands on is restated on its own: inference is observed several
|
|
166
|
+
* times a second, and recomputing the domain for each would spend the whole
|
|
167
|
+
* of that work on one attribute of one row (§5.2). */
|
|
168
|
+
private active(sid: Sid, at: Timestamp): GatewayMove {
|
|
144
169
|
const held = this.#activeAt.get(sid);
|
|
145
|
-
if (held !== undefined && held >= at) return
|
|
170
|
+
if (held !== undefined && held >= at) return "none";
|
|
171
|
+
const wasLive = held !== undefined && at - held <= GATEWAY_LIVE_WINDOW_MS;
|
|
146
172
|
this.#activeAt.set(sid, at);
|
|
147
173
|
// Sessions the gateway has not seen for longer than the window go: what is
|
|
148
174
|
// left is what any of this can still say something about.
|
|
149
|
-
|
|
175
|
+
this.prune(at);
|
|
176
|
+
return wasLive ? "clock" : "live";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Drop the sessions whose window has closed. */
|
|
180
|
+
private prune(now: Timestamp): void {
|
|
181
|
+
const floor = now - GATEWAY_LIVE_WINDOW_MS;
|
|
150
182
|
for (const [seen, when] of this.#activeAt) {
|
|
151
183
|
if (when < floor) this.#activeAt.delete(seen);
|
|
152
184
|
}
|
|
153
|
-
return true;
|
|
154
185
|
}
|
|
155
186
|
|
|
156
187
|
private notePrefix(info: LlmRequestObservation): void {
|