@ccmsg/cli 0.12.0 → 0.14.0
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/auth/admin.ts +5 -2
- package/src/auth/auth.ts +17 -17
- package/src/auth/records.ts +64 -42
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/daemon/supervise.ts +15 -7
- package/src/dispatch/dispatch.ts +4 -4
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +43 -16
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +126 -27
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +72 -18
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +73 -11
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +43 -26
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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.23.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/admin.ts
CHANGED
|
@@ -51,7 +51,10 @@ export interface Administered {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Run one administrative request. */
|
|
54
|
-
export function handleAdmin(
|
|
54
|
+
export async function handleAdmin(
|
|
55
|
+
at: Administered,
|
|
56
|
+
request: AdminRequest,
|
|
57
|
+
): Promise<DispatchResult> {
|
|
55
58
|
const auth = at.auth;
|
|
56
59
|
try {
|
|
57
60
|
switch (request.admin) {
|
|
@@ -77,7 +80,7 @@ export function handleAdmin(at: Administered, request: AdminRequest): DispatchRe
|
|
|
77
80
|
case "passkey_list":
|
|
78
81
|
return reply(request.request_id, { credentials: auth.list() });
|
|
79
82
|
case "passkey_remove": {
|
|
80
|
-
const { closed } = auth.remove(request.sub);
|
|
83
|
+
const { closed } = await auth.remove(request.sub);
|
|
81
84
|
return reply(request.request_id, { sub: request.sub, closed });
|
|
82
85
|
}
|
|
83
86
|
}
|
package/src/auth/auth.ts
CHANGED
|
@@ -316,8 +316,8 @@ export class Auth {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
/** Remove one person: the tombstones, and every connection they hold. */
|
|
319
|
-
remove(sub: Subject): { records: AuthRecord[]; closed: number } {
|
|
320
|
-
const records = this.deps.records.remove(sub);
|
|
319
|
+
async remove(sub: Subject): Promise<{ records: AuthRecord[]; closed: number }> {
|
|
320
|
+
const records = await this.deps.records.remove(sub);
|
|
321
321
|
return { records, closed: this.disconnect(sub) };
|
|
322
322
|
}
|
|
323
323
|
|
|
@@ -340,8 +340,8 @@ export class Auth {
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
/** Take what a peer wrote on `auth.records`, and act on the removals in it. */
|
|
343
|
-
merge(records: readonly AuthRecord[]): void {
|
|
344
|
-
const { removed } = this.deps.records.merge(records);
|
|
343
|
+
async merge(records: readonly AuthRecord[]): Promise<void> {
|
|
344
|
+
const { removed } = await this.deps.records.merge(records);
|
|
345
345
|
for (const sub of removed) this.disconnect(sub);
|
|
346
346
|
}
|
|
347
347
|
|
|
@@ -479,7 +479,7 @@ export class Auth {
|
|
|
479
479
|
...(from.ip === undefined ? {} : { registered_ip: from.ip }),
|
|
480
480
|
...(from.userAgent === undefined ? {} : { registered_user_agent: from.userAgent }),
|
|
481
481
|
};
|
|
482
|
-
this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
|
|
482
|
+
await this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
|
|
483
483
|
return this.mint(claims.sub);
|
|
484
484
|
}
|
|
485
485
|
|
|
@@ -488,7 +488,7 @@ export class Auth {
|
|
|
488
488
|
* Only its issuer can say, because only the issuer holds the secret that
|
|
489
489
|
* signed it — and the six digits are held beside that secret. So the digits
|
|
490
490
|
* travel there unjudged: an instance that decided them itself would let
|
|
491
|
-
* somebody spread guesses across the
|
|
491
|
+
* somebody spread guesses across the mesh without any of them counting
|
|
492
492
|
* against the URL (contract, `AuthResolveArgs`). Nothing is spent here.
|
|
493
493
|
*
|
|
494
494
|
* The claims come back from the issuer having been checked and consumed, and
|
|
@@ -589,7 +589,7 @@ export class Auth {
|
|
|
589
589
|
);
|
|
590
590
|
await this.#spendAnywhere(args.challenge);
|
|
591
591
|
const at = this.#now();
|
|
592
|
-
this.deps.records.write(
|
|
592
|
+
await this.deps.records.write(
|
|
593
593
|
credentialKey(record.sub, record.credential_id),
|
|
594
594
|
{
|
|
595
595
|
...record,
|
|
@@ -654,7 +654,7 @@ export class Auth {
|
|
|
654
654
|
// --- tokens (DR-0001 §2.4) ---
|
|
655
655
|
|
|
656
656
|
/** Make a family for this person, minted by this instance. */
|
|
657
|
-
mint(sub: Subject): MintedSession {
|
|
657
|
+
async mint(sub: Subject): Promise<MintedSession> {
|
|
658
658
|
const at = this.#now();
|
|
659
659
|
const family: TokenFamily = {
|
|
660
660
|
kind: "token_family",
|
|
@@ -664,7 +664,7 @@ export class Auth {
|
|
|
664
664
|
refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
|
|
665
665
|
};
|
|
666
666
|
const id = randomBytes(8).toString("hex");
|
|
667
|
-
this.deps.records.write(familyKey(sub, id), family, at);
|
|
667
|
+
await this.deps.records.write(familyKey(sub, id), family, at);
|
|
668
668
|
return { session: { sub, access: family.access }, refresh: family.refresh };
|
|
669
669
|
}
|
|
670
670
|
|
|
@@ -698,7 +698,7 @@ export class Auth {
|
|
|
698
698
|
} satisfies AuthRotateArgs)) as AuthRotateResult;
|
|
699
699
|
return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
|
|
700
700
|
}
|
|
701
|
-
const rotated = this.rotate(value, from);
|
|
701
|
+
const rotated = await this.rotate(value, from);
|
|
702
702
|
return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
|
|
703
703
|
}
|
|
704
704
|
|
|
@@ -714,7 +714,7 @@ export class Auth {
|
|
|
714
714
|
const owner = this.deps.records.owning(value, digestOf(value));
|
|
715
715
|
if (owner === undefined) return;
|
|
716
716
|
if (owner.body.iss === this.deps.self) {
|
|
717
|
-
this.#failReused(value);
|
|
717
|
+
await this.#failReused(value);
|
|
718
718
|
return;
|
|
719
719
|
}
|
|
720
720
|
try {
|
|
@@ -730,10 +730,10 @@ export class Auth {
|
|
|
730
730
|
|
|
731
731
|
/** Rotate a family this instance minted. The one writer's own operation, and
|
|
732
732
|
* what `auth.rotate` runs on its behalf. */
|
|
733
|
-
rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
|
|
733
|
+
async rotate(value: Base64Url, from: RefreshFrom = {}): Promise<AuthRotateResult> {
|
|
734
734
|
const held = this.deps.records.byRefresh(value);
|
|
735
735
|
if (held === undefined) {
|
|
736
|
-
this.#failReused(value);
|
|
736
|
+
await this.#failReused(value);
|
|
737
737
|
throw new OpError("auth_invalid", "この refresh token は使えません");
|
|
738
738
|
}
|
|
739
739
|
if (held.body.iss !== this.deps.self) {
|
|
@@ -777,7 +777,7 @@ export class Auth {
|
|
|
777
777
|
// holds wherever the reused value is presented (contract, `TokenFamily`).
|
|
778
778
|
retired: retire(held.body, at),
|
|
779
779
|
};
|
|
780
|
-
this.deps.records.write(held.key, rotated, at);
|
|
780
|
+
await this.deps.records.write(held.key, rotated, at);
|
|
781
781
|
return { sub: rotated.sub, access: rotated.access, refresh: rotated.refresh };
|
|
782
782
|
}
|
|
783
783
|
|
|
@@ -789,7 +789,7 @@ export class Auth {
|
|
|
789
789
|
* before it past its grace, and any generation this instance rotated away
|
|
790
790
|
* while it has been running. A value older than what any of those covers
|
|
791
791
|
* matches nothing and is refused as a stranger. */
|
|
792
|
-
#failReused(value: Base64Url): void {
|
|
792
|
+
async #failReused(value: Base64Url): Promise<void> {
|
|
793
793
|
const digest = digestOf(value);
|
|
794
794
|
const now = this.#now();
|
|
795
795
|
for (const held of this.deps.records.families()) {
|
|
@@ -805,7 +805,7 @@ export class Auth {
|
|
|
805
805
|
this.deps.log?.("a refresh token was reused after it was rotated away", {
|
|
806
806
|
sub: held.body.sub,
|
|
807
807
|
});
|
|
808
|
-
this.deps.records.fail(held.key);
|
|
808
|
+
await this.deps.records.fail(held.key);
|
|
809
809
|
// The tokens are gone, and so is what they were holding open: a
|
|
810
810
|
// connection that outlived the family it was admitted on would be the
|
|
811
811
|
// stolen token still working.
|
|
@@ -932,7 +932,7 @@ export function authHandlers(auth: Auth) {
|
|
|
932
932
|
// the URL and the count of tries against it (DR-0001 §2.2).
|
|
933
933
|
return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
|
|
934
934
|
},
|
|
935
|
-
"auth.rotate": (input: HandlerInput): AuthRotateResult => {
|
|
935
|
+
"auth.rotate": (input: HandlerInput): Promise<AuthRotateResult> => {
|
|
936
936
|
const args = input.args as unknown as AuthRotateArgs;
|
|
937
937
|
// The receiving instance's account of the person, taken as stated: it is
|
|
938
938
|
// the only one that saw them, and `last_refresh` is a hint nothing is
|
package/src/auth/records.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import type {
|
|
4
5
|
AuthRecord,
|
|
5
6
|
AuthTombstone,
|
|
@@ -81,9 +82,13 @@ export interface RecordsDeps {
|
|
|
81
82
|
* and in the authenticator, and losing a family logs its person out. */
|
|
82
83
|
export class AuthRecords {
|
|
83
84
|
readonly #records = new Map<string, AuthRecord>();
|
|
84
|
-
#loaded = false;
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
/** The writes already asked for, as one chain. */
|
|
87
|
+
#writing: Promise<void> = Promise.resolve();
|
|
88
|
+
|
|
89
|
+
constructor(private readonly deps: RecordsDeps) {
|
|
90
|
+
this.#read();
|
|
91
|
+
}
|
|
87
92
|
|
|
88
93
|
#now(): Timestamp {
|
|
89
94
|
return (this.deps.now ?? Date.now)();
|
|
@@ -94,7 +99,6 @@ export class AuthRecords {
|
|
|
94
99
|
* Answers whether the set moved, which is what decides whether the change is
|
|
95
100
|
* worth writing down and passing on. */
|
|
96
101
|
accept(record: AuthRecord): boolean {
|
|
97
|
-
this.#load();
|
|
98
102
|
const held = this.#records.get(record.key);
|
|
99
103
|
if (held !== undefined && held.updated_at >= record.updated_at) return false;
|
|
100
104
|
if (this.#refused(record)) return false;
|
|
@@ -127,7 +131,7 @@ export class AuthRecords {
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
/** Write one record of this instance's own, and tell the
|
|
134
|
+
/** Write one record of this instance's own, and tell the mesh.
|
|
131
135
|
*
|
|
132
136
|
* A local write always displaces what the key holds. Last-write-wins settles
|
|
133
137
|
* a disagreement between instances; this is not one — the writer is the
|
|
@@ -135,13 +139,18 @@ export class AuthRecords {
|
|
|
135
139
|
* millisecond, which a rotation and the mint before it easily do, must not
|
|
136
140
|
* silently drop the second. So the instant is moved past what is held rather
|
|
137
141
|
* than compared against it. */
|
|
138
|
-
write(
|
|
139
|
-
|
|
142
|
+
async write(
|
|
143
|
+
key: string,
|
|
144
|
+
body: AuthRecord["body"],
|
|
145
|
+
now: Timestamp = this.#now(),
|
|
146
|
+
): Promise<boolean> {
|
|
140
147
|
const held = this.#records.get(key);
|
|
141
148
|
const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
|
|
142
149
|
const record: AuthRecord = { key, updated_at: at, body };
|
|
143
150
|
if (!this.accept(record)) return false;
|
|
144
|
-
|
|
151
|
+
// Handed to the peers once it is written down, so no peer holds a record
|
|
152
|
+
// this instance would not have after a restart.
|
|
153
|
+
await this.#persist();
|
|
145
154
|
this.deps.publish([record]);
|
|
146
155
|
return true;
|
|
147
156
|
}
|
|
@@ -156,7 +165,7 @@ export class AuthRecords {
|
|
|
156
165
|
* This instance is its only writer, so a copy coming back is a copy of an
|
|
157
166
|
* older state — which is exactly what a failed family looks like from a peer
|
|
158
167
|
* that has not heard yet, and taking it would undo the failure. */
|
|
159
|
-
merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
|
|
168
|
+
async merge(records: readonly AuthRecord[]): Promise<{ changed: number; removed: Subject[] }> {
|
|
160
169
|
let changed = 0;
|
|
161
170
|
const removed: Subject[] = [];
|
|
162
171
|
for (const record of records) {
|
|
@@ -165,7 +174,7 @@ export class AuthRecords {
|
|
|
165
174
|
changed += 1;
|
|
166
175
|
if (record.body.kind === "tombstone") removed.push(record.body.sub);
|
|
167
176
|
}
|
|
168
|
-
if (changed > 0) this.#persist();
|
|
177
|
+
if (changed > 0) await this.#persist();
|
|
169
178
|
return { changed, removed };
|
|
170
179
|
}
|
|
171
180
|
|
|
@@ -175,7 +184,7 @@ export class AuthRecords {
|
|
|
175
184
|
* lengths of time. A family expires with its refresh token, so the mark over
|
|
176
185
|
* it only has to outlive the longest one; a credential has no expiry of its
|
|
177
186
|
* own, so the mark over it has none either (DR-0001 §2.6). */
|
|
178
|
-
remove(sub: Subject): AuthRecord[] {
|
|
187
|
+
async remove(sub: Subject): Promise<AuthRecord[]> {
|
|
179
188
|
const at = this.#now();
|
|
180
189
|
const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
|
|
181
190
|
const family: AuthTombstone = {
|
|
@@ -189,7 +198,7 @@ export class AuthRecords {
|
|
|
189
198
|
{ key: familyPrefix(sub), updated_at: at, body: family },
|
|
190
199
|
];
|
|
191
200
|
for (const mark of marks) this.accept(mark);
|
|
192
|
-
this.#persist();
|
|
201
|
+
await this.#persist();
|
|
193
202
|
this.deps.publish(marks);
|
|
194
203
|
return marks;
|
|
195
204
|
}
|
|
@@ -197,13 +206,11 @@ export class AuthRecords {
|
|
|
197
206
|
/** Whether this subject has been removed, which is what a registration for
|
|
198
207
|
* one has to be refused by. */
|
|
199
208
|
removed(sub: Subject): boolean {
|
|
200
|
-
this.#load();
|
|
201
209
|
const held = this.#records.get(credentialPrefix(sub));
|
|
202
210
|
return held?.body.kind === "tombstone";
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
credentials(): CredentialRecord[] {
|
|
206
|
-
this.#load();
|
|
207
214
|
const found: CredentialRecord[] = [];
|
|
208
215
|
for (const record of this.#records.values()) {
|
|
209
216
|
if (record.body.kind === "credential") found.push(record.body);
|
|
@@ -228,7 +235,6 @@ export class AuthRecords {
|
|
|
228
235
|
}
|
|
229
236
|
|
|
230
237
|
families(): { key: string; body: TokenFamily }[] {
|
|
231
|
-
this.#load();
|
|
232
238
|
const found: { key: string; body: TokenFamily }[] = [];
|
|
233
239
|
for (const record of this.#records.values()) {
|
|
234
240
|
if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
|
|
@@ -275,12 +281,11 @@ export class AuthRecords {
|
|
|
275
281
|
* the key, which is exactly what a revoked family needs. It is kept for the
|
|
276
282
|
* same seven days a removal's is: past the longest refresh token, there is
|
|
277
283
|
* nothing left for a returning peer to revive. */
|
|
278
|
-
fail(key: string): void {
|
|
279
|
-
this.#load();
|
|
284
|
+
async fail(key: string): Promise<void> {
|
|
280
285
|
const held = this.#records.get(key);
|
|
281
286
|
if (held === undefined || held.body.kind !== "token_family") return;
|
|
282
287
|
const at = this.#now();
|
|
283
|
-
this.write(key, {
|
|
288
|
+
await this.write(key, {
|
|
284
289
|
kind: "tombstone",
|
|
285
290
|
sub: held.body.sub,
|
|
286
291
|
deleted_at: at,
|
|
@@ -306,7 +311,6 @@ export class AuthRecords {
|
|
|
306
311
|
|
|
307
312
|
/** Every record, for the snapshot a peer's subscription is answered with. */
|
|
308
313
|
all(): AuthRecord[] {
|
|
309
|
-
this.#load();
|
|
310
314
|
this.#expire();
|
|
311
315
|
return [...this.#records.values()];
|
|
312
316
|
}
|
|
@@ -330,9 +334,19 @@ export class AuthRecords {
|
|
|
330
334
|
}
|
|
331
335
|
}
|
|
332
336
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
/** Settle once every write asked for so far has landed. What a stop waits on
|
|
338
|
+
* before it lets go of the config home (DESIGN §8.5 step 4). */
|
|
339
|
+
async flush(): Promise<void> {
|
|
340
|
+
await this.#writing;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The file, read as this is built — before the instance is accepting
|
|
344
|
+
* anything, so nobody is waiting on it (DR-0015). Reading it when the first
|
|
345
|
+
* authentication asked would put the read inside the turn that answers it,
|
|
346
|
+
* and the `auth.records` snapshot is answered from what is held rather than
|
|
347
|
+
* from a promise. A file that is not there is an instance nobody has
|
|
348
|
+
* registered against, which is what an empty set says. */
|
|
349
|
+
#read(): void {
|
|
336
350
|
let text: string;
|
|
337
351
|
try {
|
|
338
352
|
text = readFileSync(this.#file(), "utf8");
|
|
@@ -356,21 +370,34 @@ export class AuthRecords {
|
|
|
356
370
|
this.#expire();
|
|
357
371
|
}
|
|
358
372
|
|
|
359
|
-
|
|
373
|
+
/** The set as it stands, written whole.
|
|
374
|
+
*
|
|
375
|
+
* The body is taken here, before anything is awaited, so what is written is
|
|
376
|
+
* the set as it was when the change was answered; the writes are chained so
|
|
377
|
+
* that two of them cannot be racing for one file and the older land last
|
|
378
|
+
* (DR-0015). */
|
|
379
|
+
#persist(): Promise<void> {
|
|
360
380
|
this.#expire();
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
381
|
+
const body = JSON.stringify([...this.#records.values()]);
|
|
382
|
+
const written = this.#writing.then(async () => {
|
|
383
|
+
await mkdir(this.deps.dir, { recursive: true, mode: 0o700 });
|
|
384
|
+
const file = this.#file();
|
|
385
|
+
const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
|
|
386
|
+
// The set holds tokens, so the file is the instance's own to read: it is
|
|
387
|
+
// created with the mode rather than fixed afterwards, so there is no
|
|
388
|
+
// instant at which it stands readable by anyone else.
|
|
389
|
+
await writeFile(temporary, body, { mode: 0o600 });
|
|
390
|
+
try {
|
|
391
|
+
await rename(temporary, file);
|
|
392
|
+
} catch (cause) {
|
|
393
|
+
await unlink(temporary);
|
|
394
|
+
throw cause;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
// The chain carries the order, not the outcome: a write that failed is
|
|
398
|
+
// answered to its own caller, and the ones behind it still go.
|
|
399
|
+
this.#writing = written.catch(() => {});
|
|
400
|
+
return written;
|
|
374
401
|
}
|
|
375
402
|
|
|
376
403
|
#file(): string {
|
|
@@ -383,11 +410,6 @@ export function recordsDir(stateDir: string): string {
|
|
|
383
410
|
return join(stateDir, AUTH_DIR);
|
|
384
411
|
}
|
|
385
412
|
|
|
386
|
-
/** The parent of a path, made when a caller wants to write into it. */
|
|
387
|
-
export function ensureDir(file: string): void {
|
|
388
|
-
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
413
|
/** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
|
|
392
414
|
export function writes(family: TokenFamily, self: InstanceId): boolean {
|
|
393
415
|
return family.iss === self;
|
package/src/cli.ts
CHANGED
|
@@ -434,7 +434,7 @@ const ROOT: Command = {
|
|
|
434
434
|
{
|
|
435
435
|
name: "dump",
|
|
436
436
|
summary: "セッション (か配下の worker 1 体) の transcript を型ごとの表示で書き出す",
|
|
437
|
-
usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
|
|
437
|
+
usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>] [--format <形式>]",
|
|
438
438
|
options: [
|
|
439
439
|
["--preset <名前>", "instance が持つ選択 (ccmsg dump presets で一覧)"],
|
|
440
440
|
["--types <選択>", "型をカンマ区切りで。prefix 可、-で除外、@名前で preset 展開"],
|
|
@@ -444,6 +444,10 @@ const ROOT: Command = {
|
|
|
444
444
|
],
|
|
445
445
|
["--until <at|ago|uuid>", "上限。同上"],
|
|
446
446
|
["--max-chars <n>", "1 アイテムの本文をこの文字数で切る (既定は切らない)"],
|
|
447
|
+
[
|
|
448
|
+
"--format <形式>",
|
|
449
|
+
"instance に書かせる形式: items (型付き JSON) / records (元の jsonl) / text (markdown)",
|
|
450
|
+
],
|
|
447
451
|
["--json", "markdown ではなく dump file の中身をそのまま出す"],
|
|
448
452
|
["--out <path>", "標準出力ではなくこの path に書く"],
|
|
449
453
|
],
|
|
@@ -1181,7 +1185,7 @@ function agents(args: readonly string[]): Promise<unknown> {
|
|
|
1181
1185
|
});
|
|
1182
1186
|
}
|
|
1183
1187
|
|
|
1184
|
-
/** Read the current value of a
|
|
1188
|
+
/** Read the current value of a mesh topic and answer with it.
|
|
1185
1189
|
*
|
|
1186
1190
|
* One entry per instance, carrying the topic's payload exactly as the contract
|
|
1187
1191
|
* defines it: a whole value per instance is not something to merge into one
|
|
@@ -1221,7 +1225,11 @@ async function topic(
|
|
|
1221
1225
|
*
|
|
1222
1226
|
* `--json` hands over the file as it stands, for a reader that is a program. */
|
|
1223
1227
|
async function dump(args: readonly string[]): Promise<unknown> {
|
|
1224
|
-
const parsed = options(
|
|
1228
|
+
const parsed = options(
|
|
1229
|
+
args,
|
|
1230
|
+
["preset", "types", "since", "until", "out", "max-chars", "format"],
|
|
1231
|
+
["json"],
|
|
1232
|
+
);
|
|
1225
1233
|
const subject = parsed.rest[0];
|
|
1226
1234
|
if (subject === undefined) {
|
|
1227
1235
|
throw new CommandError(
|
|
@@ -1234,6 +1242,11 @@ async function dump(args: readonly string[]): Promise<unknown> {
|
|
|
1234
1242
|
...dumpArgs(subject, parsed.named),
|
|
1235
1243
|
})) as unknown as SessionDumpWriteResult;
|
|
1236
1244
|
const body = readFileSync(written.path, "utf8");
|
|
1245
|
+
// A format the instance was asked for is a file it wrote for that purpose,
|
|
1246
|
+
// so it is handed over as it stands: rendering it again here would be this
|
|
1247
|
+
// command deciding what a caller already decided.
|
|
1248
|
+
const format = parsed.named.get("format");
|
|
1249
|
+
if (format !== undefined) return await handed(body, parsed.named.get("out"));
|
|
1237
1250
|
// What the heading states is where the cut fell, not the words it was asked
|
|
1238
1251
|
// for in: a dump read next week cannot work out what "10 minutes ago" was.
|
|
1239
1252
|
const since = spelled(resolved("since", parsed.named.get("since")));
|
|
@@ -1269,6 +1282,16 @@ async function dump(args: readonly string[]): Promise<unknown> {
|
|
|
1269
1282
|
* two apart so that a sid stays a validated sid, and the joined spelling is
|
|
1270
1283
|
* the CLI's own convenience — it is how the file the agent's records live in
|
|
1271
1284
|
* is named, which is what makes the two halves tellable apart by eye. */
|
|
1285
|
+
/** Write out what the instance wrote, wherever the caller wanted it. */
|
|
1286
|
+
async function handed(body: string, out: string | undefined): Promise<undefined> {
|
|
1287
|
+
if (out === undefined) {
|
|
1288
|
+
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
await Bun.write(out, body);
|
|
1292
|
+
return undefined;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1272
1295
|
export function dumpArgs(
|
|
1273
1296
|
subject: string,
|
|
1274
1297
|
named: ReadonlyMap<string, string> = new Map(),
|
|
@@ -1278,8 +1301,13 @@ export function dumpArgs(
|
|
|
1278
1301
|
const agent = at === -1 ? undefined : subject.slice(at + AGENT_MARK.length);
|
|
1279
1302
|
const types = named.get("types");
|
|
1280
1303
|
const preset = named.get("preset");
|
|
1304
|
+
const format = named.get("format");
|
|
1305
|
+
if (format !== undefined && format !== "items" && format !== "records" && format !== "text") {
|
|
1306
|
+
throw new CommandError("invalid_args", "--format は items / records / text のどれかです");
|
|
1307
|
+
}
|
|
1281
1308
|
return {
|
|
1282
1309
|
sid,
|
|
1310
|
+
...(format === undefined ? {} : { format }),
|
|
1283
1311
|
...(agent === undefined || agent === "" ? {} : { agent_id: agent }),
|
|
1284
1312
|
...(preset === undefined ? {} : { preset }),
|
|
1285
1313
|
...(types === undefined
|
|
@@ -1418,16 +1446,21 @@ function reply(args: readonly string[]): Promise<unknown> {
|
|
|
1418
1446
|
* line on the page they are watching, and the machine saying it aloud. Both
|
|
1419
1447
|
* run whatever the tool itself decided to do with it. */
|
|
1420
1448
|
export async function notify(args: readonly string[], read?: Read): Promise<unknown> {
|
|
1421
|
-
const parsed = options(args, ["sid", "about"], ["hook"]);
|
|
1449
|
+
const parsed = options(args, ["sid", "about", "reply-to"], ["hook"]);
|
|
1422
1450
|
if (parsed.flags.has("hook")) return await pushed(await hookEvent(read));
|
|
1423
1451
|
const [text] = parsed.rest;
|
|
1424
1452
|
if (text === undefined) {
|
|
1425
|
-
throw new CommandError(
|
|
1453
|
+
throw new CommandError(
|
|
1454
|
+
"invalid_args",
|
|
1455
|
+
"使い方: ccmsg notify <text> [--about <sid>] [--reply-to <mid>] | --hook",
|
|
1456
|
+
);
|
|
1426
1457
|
}
|
|
1427
1458
|
const about = parsed.named.get("about");
|
|
1459
|
+
const answering = parsed.named.get("reply-to");
|
|
1428
1460
|
return await announce(parsed.named.get("sid"), {
|
|
1429
1461
|
text,
|
|
1430
1462
|
...(about === undefined ? {} : { sid: about }),
|
|
1463
|
+
...(answering === undefined ? {} : { reply_to: answering as NotifySendArgs["reply_to"] }),
|
|
1431
1464
|
});
|
|
1432
1465
|
}
|
|
1433
1466
|
|
package/src/daemon/snapshot.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { CommandError } from "./link.ts";
|
|
|
6
6
|
*
|
|
7
7
|
* The pair rather than the payload alone, because two instances state the same
|
|
8
8
|
* topic name: a payload with the name of its author taken off could not be told
|
|
9
|
-
* from the other's, and which instance said it is half of what a
|
|
9
|
+
* from the other's, and which instance said it is half of what a mesh read
|
|
10
10
|
* answers. */
|
|
11
11
|
export interface Snapshot {
|
|
12
12
|
readonly instance: InstanceId;
|
|
@@ -31,7 +31,7 @@ interface Frame {
|
|
|
31
31
|
readonly data?: unknown;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** What a greeting answered, as the two fields a
|
|
34
|
+
/** What a greeting answered, as the two fields a mesh read needs of it. */
|
|
35
35
|
interface Greeted {
|
|
36
36
|
readonly instance?: unknown;
|
|
37
37
|
readonly instances?: unknown;
|
|
@@ -92,7 +92,7 @@ export async function snapshots(
|
|
|
92
92
|
return [...held].map(([instance, data]) => ({ instance, data }));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** The instances a greeting named, as the set a read of a
|
|
95
|
+
/** The instances a greeting named, as the set a read of a mesh topic waits
|
|
96
96
|
* for.
|
|
97
97
|
*
|
|
98
98
|
* `all` is every instance the answering one can currently reach, itself
|
package/src/daemon/supervise.ts
CHANGED
|
@@ -275,18 +275,26 @@ export class Supervisor {
|
|
|
275
275
|
}
|
|
276
276
|
return await op(unit);
|
|
277
277
|
}
|
|
278
|
-
const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
|
|
279
278
|
// Taken as a list first: each step below waits, and a request arriving in
|
|
280
279
|
// between may add or remove one — what `--all` answers about is the set as
|
|
281
280
|
// it stood when it was asked.
|
|
282
281
|
const units = Array.from(this.#units.values());
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
// Every config home at once. Each is a child process of its own, and a
|
|
283
|
+
// start that waits on one of them serving is not a reason the next one has
|
|
284
|
+
// not been asked to start yet (DR-0015). The answers are still the units in
|
|
285
|
+
// the order they were taken, because that is the list the caller asked
|
|
286
|
+
// about.
|
|
287
|
+
const settled = await Promise.allSettled(units.map((unit) => op(unit)));
|
|
288
|
+
const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
|
|
289
|
+
for (const [index, outcome] of settled.entries()) {
|
|
290
|
+
if (outcome.status === "fulfilled") {
|
|
291
|
+
answers.push(outcome.value);
|
|
292
|
+
continue;
|
|
289
293
|
}
|
|
294
|
+
const cause: unknown = outcome.reason;
|
|
295
|
+
if (!(cause instanceof CommandError)) throw cause;
|
|
296
|
+
const unit = units[index] as Supervised;
|
|
297
|
+
answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
|
|
290
298
|
}
|
|
291
299
|
return answers;
|
|
292
300
|
}
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -14,11 +14,11 @@ import { type DispatchResult, failure, OpError, reply } from "./result.ts";
|
|
|
14
14
|
/** What dispatch needs from the instance around it. */
|
|
15
15
|
export interface DispatchDeps {
|
|
16
16
|
/** This instance's own id, compared with the destination of an
|
|
17
|
-
* `
|
|
17
|
+
* `owner_instance` op to decide whether the op is ours to run. */
|
|
18
18
|
readonly self: InstanceId;
|
|
19
19
|
/** The capabilities this instance has, as `hello` reports them. */
|
|
20
20
|
readonly capabilities: ReadonlySet<Capability>;
|
|
21
|
-
/** The instance that owns the subject of an `
|
|
21
|
+
/** The instance that owns the subject of an `owner_instance` op, or
|
|
22
22
|
* `undefined` when no other instance owns it and we answer ourselves.
|
|
23
23
|
* The routing table behind this is the `peers` topic (DESIGN §7.3). */
|
|
24
24
|
readonly resolveInstance: (op: OpName, frame: Record<string, unknown>) => InstanceId | undefined;
|
|
@@ -97,7 +97,7 @@ export async function dispatch(
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// 6. an
|
|
100
|
+
// 6. an owner_instance op whose subject belongs elsewhere goes to mesh.
|
|
101
101
|
//
|
|
102
102
|
// A request that has already been here is dropped before that: a cycle in
|
|
103
103
|
// the routing would otherwise send it round the same instances until every
|
|
@@ -108,7 +108,7 @@ export async function dispatch(
|
|
|
108
108
|
if (Array.isArray(hops) && hops.includes(deps.self)) {
|
|
109
109
|
return failure(requestId, "instance_unreachable", `${op} came back to ${deps.self}`);
|
|
110
110
|
}
|
|
111
|
-
if (attrs.locality === "
|
|
111
|
+
if (attrs.locality === "owner_instance") {
|
|
112
112
|
const asked = fields["to_instance"];
|
|
113
113
|
const target = typeof asked === "string" ? asked : deps.resolveInstance(op, fields);
|
|
114
114
|
if (target !== undefined && target !== deps.self) {
|