@teammy/cli 0.0.7 → 0.0.9
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/README.md +2 -2
- package/dist/main.js +592 -348
- package/package.json +2 -1
package/dist/main.js
CHANGED
|
@@ -257,7 +257,7 @@ var SessionSearchSchema = z4.object({
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
// packages/transport/hub-client.ts
|
|
260
|
-
import { z as
|
|
260
|
+
import { z as z7 } from "zod";
|
|
261
261
|
import { createHash } from "node:crypto";
|
|
262
262
|
|
|
263
263
|
// packages/protocol/index.ts
|
|
@@ -278,6 +278,33 @@ var ErrorResponseSchema = z5.object({
|
|
|
278
278
|
})
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
// packages/protocol/shared-files.ts
|
|
282
|
+
import { z as z6 } from "zod";
|
|
283
|
+
var PROJECT_FILE_OWNER = "_project";
|
|
284
|
+
var FileOwnerSchema = z6.union([NameSchema, z6.literal(PROJECT_FILE_OWNER)]);
|
|
285
|
+
var SharedPathSchema = z6.string().max(255).refine((path) => {
|
|
286
|
+
const directory = path.endsWith("/");
|
|
287
|
+
const name2 = directory ? path.slice(0, -1) : path;
|
|
288
|
+
return FilePathSchema.safeParse(name2).success && !name2.includes("/") && !name2.startsWith(".") && !/^_?teammy/i.test(name2) && (directory ? name2.startsWith("_") : name2.includes("."));
|
|
289
|
+
}, "Use a root file (README.md) or an underscore-prefixed directory (_template/).");
|
|
290
|
+
var SharedFilesConfigSchema = z6.object({
|
|
291
|
+
maintainer: NameSchema.nullable(),
|
|
292
|
+
paths: z6.array(SharedPathSchema).max(30).refine((paths) => new Set(paths.map((p) => p.toLowerCase())).size === paths.length),
|
|
293
|
+
revision: RevisionSchema
|
|
294
|
+
}).strict();
|
|
295
|
+
var SharedFilesUpdateSchema = SharedFilesConfigSchema.omit({ revision: true }).extend({ expectedRevision: RevisionSchema }).strict();
|
|
296
|
+
var SharedFileCommitSchema = FileCommitSchema.safeExtend({
|
|
297
|
+
configRevision: RevisionSchema
|
|
298
|
+
});
|
|
299
|
+
function privateWorkspacePath(path) {
|
|
300
|
+
return path.split("/").some((part) => /^\.teammy(?:[-.]|$)/i.test(part));
|
|
301
|
+
}
|
|
302
|
+
function sharedPathAllowed(path, paths) {
|
|
303
|
+
return !privateWorkspacePath(path) && paths.some(
|
|
304
|
+
(entry) => entry.endsWith("/") ? path === entry.slice(0, -1) || path.startsWith(entry) : path === entry
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
281
308
|
// packages/transport/hub-client.ts
|
|
282
309
|
var HubRequestError = class extends Error {
|
|
283
310
|
constructor(message2, status, code, requestId) {
|
|
@@ -340,7 +367,7 @@ var HubClient = class {
|
|
|
340
367
|
"GET",
|
|
341
368
|
`api/v1/blobs/${HashSchema.parse(hash2)}`
|
|
342
369
|
);
|
|
343
|
-
if (!response.ok) return this.decode(response, signal,
|
|
370
|
+
if (!response.ok) return this.decode(response, signal, z7.never());
|
|
344
371
|
const reader = response.body?.getReader();
|
|
345
372
|
if (!reader) throw new Error("Server returned no content body.");
|
|
346
373
|
const parts = [];
|
|
@@ -385,7 +412,7 @@ var HubClient = class {
|
|
|
385
412
|
return this.request(
|
|
386
413
|
"PUT",
|
|
387
414
|
"api/v1/devices/self/status",
|
|
388
|
-
|
|
415
|
+
z7.object({ ok: z7.literal(true) }),
|
|
389
416
|
status
|
|
390
417
|
);
|
|
391
418
|
}
|
|
@@ -395,6 +422,15 @@ var HubClient = class {
|
|
|
395
422
|
projectConfig() {
|
|
396
423
|
return this.request("GET", "api/v1/project/config", ProjectConfigSchema);
|
|
397
424
|
}
|
|
425
|
+
sharedFilesConfig() {
|
|
426
|
+
return this.request("GET", "api/v1/project/shared-files", SharedFilesConfigSchema);
|
|
427
|
+
}
|
|
428
|
+
commitSharedFiles(batch, configRevision) {
|
|
429
|
+
return this.request("POST", "api/v1/files/_project/commits", CommitResultSchema, {
|
|
430
|
+
...batch,
|
|
431
|
+
configRevision
|
|
432
|
+
});
|
|
433
|
+
}
|
|
398
434
|
commitSession(commit) {
|
|
399
435
|
return this.request(
|
|
400
436
|
"POST",
|
|
@@ -488,48 +524,6 @@ import { homedir } from "node:os";
|
|
|
488
524
|
import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
|
|
489
525
|
import { parseArgs } from "node:util";
|
|
490
526
|
|
|
491
|
-
// apps/client/runtime/state.ts
|
|
492
|
-
var runtimeMigration = `
|
|
493
|
-
ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
|
|
494
|
-
UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
|
|
495
|
-
CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
|
|
496
|
-
CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
|
|
497
|
-
`;
|
|
498
|
-
var RuntimeState = class {
|
|
499
|
-
constructor(state) {
|
|
500
|
-
this.state = state;
|
|
501
|
-
}
|
|
502
|
-
state;
|
|
503
|
-
enabled(root) {
|
|
504
|
-
return Boolean(
|
|
505
|
-
this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
enable(root, enabled) {
|
|
509
|
-
if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
|
|
510
|
-
this.state.db.prepare(
|
|
511
|
-
"INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
|
|
512
|
-
).run(root, Number(enabled));
|
|
513
|
-
}
|
|
514
|
-
roots() {
|
|
515
|
-
return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
|
|
516
|
-
}
|
|
517
|
-
status(root) {
|
|
518
|
-
const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
|
|
519
|
-
return row?.status_json ? JSON.parse(String(row.status_json)) : null;
|
|
520
|
-
}
|
|
521
|
-
save(root, status) {
|
|
522
|
-
this.state.db.prepare(
|
|
523
|
-
"INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
524
|
-
).run(root, JSON.stringify(status));
|
|
525
|
-
}
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
// apps/client/state.ts
|
|
529
|
-
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
530
|
-
import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
|
|
531
|
-
import { z as z8 } from "zod";
|
|
532
|
-
|
|
533
527
|
// packages/state/sqlite.ts
|
|
534
528
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
535
529
|
import { dirname } from "node:path";
|
|
@@ -571,6 +565,317 @@ function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
|
|
|
571
565
|
}
|
|
572
566
|
}
|
|
573
567
|
|
|
568
|
+
// apps/client/files/publication-state.ts
|
|
569
|
+
var FilePublicationState = class {
|
|
570
|
+
constructor(db, scope = "member") {
|
|
571
|
+
this.db = db;
|
|
572
|
+
const prefix = scope === "project" ? "project_" : "";
|
|
573
|
+
this.tables = {
|
|
574
|
+
publications: prefix + "publications",
|
|
575
|
+
owner_files: prefix + "owner_files",
|
|
576
|
+
outbox: prefix + "outbox"
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
db;
|
|
580
|
+
tables;
|
|
581
|
+
discard(root) {
|
|
582
|
+
this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
|
|
583
|
+
}
|
|
584
|
+
publication(root) {
|
|
585
|
+
this.db.prepare(`INSERT OR IGNORE INTO ${this.tables.publications}(root) VALUES (?)`).run(root);
|
|
586
|
+
const row = this.db.prepare(`SELECT revision, initialized FROM ${this.tables.publications} WHERE root=?`).get(root);
|
|
587
|
+
return { revision: Number(row.revision), initialized: Boolean(row.initialized) };
|
|
588
|
+
}
|
|
589
|
+
initializePublication(root, revision, files, options = {}) {
|
|
590
|
+
transaction(this.db, () => {
|
|
591
|
+
if (options.discardPending) this.discard(root);
|
|
592
|
+
if (this.pending(root))
|
|
593
|
+
throw new Error("Cannot replace a baseline with a publication pending.");
|
|
594
|
+
this.db.prepare(
|
|
595
|
+
`INSERT INTO ${this.tables.publications} VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1`
|
|
596
|
+
).run(root, revision);
|
|
597
|
+
this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=?`).run(root);
|
|
598
|
+
for (const file of files)
|
|
599
|
+
this.db.prepare(`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?)`).run(root, file.path, file.sha256, file.size);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
ownerFiles(root) {
|
|
603
|
+
return new Map(
|
|
604
|
+
this.db.prepare(`SELECT path, hash, size FROM ${this.tables.owner_files} WHERE root=?`).all(root).map((row) => [String(row.path), { sha256: String(row.hash), size: Number(row.size) }])
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
pending(root) {
|
|
608
|
+
const row = this.db.prepare(
|
|
609
|
+
`SELECT request_json, attempts, retry_at, last_error FROM ${this.tables.outbox} WHERE root=?`
|
|
610
|
+
).get(root);
|
|
611
|
+
return row ? {
|
|
612
|
+
batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
|
|
613
|
+
attempts: Number(row.attempts),
|
|
614
|
+
retryAt: Number(row.retry_at),
|
|
615
|
+
lastError: row.last_error === null ? null : String(row.last_error)
|
|
616
|
+
} : void 0;
|
|
617
|
+
}
|
|
618
|
+
enqueue(root, batch) {
|
|
619
|
+
const fixed = FileCommitSchema.parse(batch);
|
|
620
|
+
transaction(this.db, () => {
|
|
621
|
+
const publication = this.publication(root);
|
|
622
|
+
if (!publication.initialized || publication.revision !== fixed.parentRevision)
|
|
623
|
+
throw new Error("Publication baseline is not ready.");
|
|
624
|
+
if (this.pending(root)) throw new Error("A publication is already pending.");
|
|
625
|
+
this.db.prepare(`INSERT INTO ${this.tables.outbox}(root, request_json) VALUES (?, ?)`).run(root, JSON.stringify(fixed));
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
acknowledge(root, result2) {
|
|
629
|
+
transaction(this.db, () => {
|
|
630
|
+
const pending = this.pending(root);
|
|
631
|
+
if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
|
|
632
|
+
throw new Error("Unexpected publication acknowledgement.");
|
|
633
|
+
for (const change of pending.batch.changes) {
|
|
634
|
+
if (change.kind === "delete")
|
|
635
|
+
this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=? AND path=?`).run(root, change.path);
|
|
636
|
+
else
|
|
637
|
+
this.db.prepare(
|
|
638
|
+
`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size`
|
|
639
|
+
).run(root, change.path, change.sha256, change.size);
|
|
640
|
+
}
|
|
641
|
+
this.db.prepare(`UPDATE ${this.tables.publications} SET revision=? WHERE root=?`).run(result2.revision, root);
|
|
642
|
+
this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
postpone(root, operationId, reason, now, blocked) {
|
|
646
|
+
const pending = this.pending(root);
|
|
647
|
+
if (!pending || pending.batch.operationId !== operationId) return;
|
|
648
|
+
const retryAt = blocked ? Number.MAX_SAFE_INTEGER : now + Math.min(9e5, 6e4 * 2 ** Math.min(pending.attempts, 4));
|
|
649
|
+
this.db.prepare(
|
|
650
|
+
`UPDATE ${this.tables.outbox} SET attempts=attempts+1, retry_at=?, last_error=? WHERE root=?`
|
|
651
|
+
).run(retryAt, reason, root);
|
|
652
|
+
}
|
|
653
|
+
retryPublication(root) {
|
|
654
|
+
this.db.prepare(`UPDATE ${this.tables.outbox} SET retry_at=0, last_error=NULL WHERE root=?`).run(root);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// apps/client/files/state.ts
|
|
659
|
+
var syncMigration = String.raw`
|
|
660
|
+
CREATE TABLE file_index (
|
|
661
|
+
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT, dirty INTEGER NOT NULL DEFAULT 1,
|
|
662
|
+
event_pending INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 1,
|
|
663
|
+
observed_at REAL NOT NULL, hash TEXT, verified_at REAL NOT NULL DEFAULT 0,
|
|
664
|
+
PRIMARY KEY(root, path)
|
|
665
|
+
);
|
|
666
|
+
CREATE INDEX dirty_files ON file_index(root, dirty, observed_at);
|
|
667
|
+
CREATE INDEX audit_files ON file_index(root, dirty, verified_at);
|
|
668
|
+
CREATE TABLE received_files (root TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, owner, path));
|
|
669
|
+
CREATE TABLE received_progress (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(root, owner));
|
|
670
|
+
CREATE TABLE receive_jobs (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, changes_json TEXT NOT NULL, next_index INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root, owner));
|
|
671
|
+
CREATE TABLE sync_status (root TEXT PRIMARY KEY, status_json TEXT NOT NULL);
|
|
672
|
+
`;
|
|
673
|
+
var SyncState = class {
|
|
674
|
+
constructor(state, root, scope = "member") {
|
|
675
|
+
this.root = root;
|
|
676
|
+
this.db = state.db;
|
|
677
|
+
const prefix = scope === "project" ? "project_" : "";
|
|
678
|
+
this.tables = {
|
|
679
|
+
file_index: prefix + "file_index",
|
|
680
|
+
received_files: prefix + "received_files",
|
|
681
|
+
received_progress: prefix + "received_progress",
|
|
682
|
+
receive_jobs: prefix + "receive_jobs",
|
|
683
|
+
sync_status: prefix + "sync_status"
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
root;
|
|
687
|
+
db;
|
|
688
|
+
tables;
|
|
689
|
+
index(onlyDirty = false) {
|
|
690
|
+
return this.db.prepare(
|
|
691
|
+
`SELECT * FROM ${this.tables.file_index} WHERE root=?${onlyDirty ? " AND dirty=1" : ""}`
|
|
692
|
+
).all(this.root).map((row) => ({
|
|
693
|
+
path: String(row.path),
|
|
694
|
+
signature: row.signature === null ? null : String(row.signature),
|
|
695
|
+
dirty: Boolean(row.dirty),
|
|
696
|
+
eventPending: Boolean(row.event_pending),
|
|
697
|
+
generation: Number(row.generation),
|
|
698
|
+
observedAt: Number(row.observed_at),
|
|
699
|
+
hash: row.hash === null ? null : String(row.hash),
|
|
700
|
+
verifiedAt: Number(row.verified_at),
|
|
701
|
+
seenLocal: Boolean(row.seen_local)
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
audit(before, limit) {
|
|
705
|
+
return this.db.prepare(
|
|
706
|
+
`SELECT path FROM ${this.tables.file_index} WHERE root=? AND dirty=0 AND signature IS NOT NULL AND verified_at<=? ORDER BY verified_at LIMIT ?`
|
|
707
|
+
).all(this.root, before, limit).map((row) => this.get(String(row.path)));
|
|
708
|
+
}
|
|
709
|
+
get(path) {
|
|
710
|
+
const row = this.db.prepare(`SELECT * FROM ${this.tables.file_index} WHERE root=? AND path=?`).get(this.root, path);
|
|
711
|
+
return row ? {
|
|
712
|
+
path,
|
|
713
|
+
signature: row.signature === null ? null : String(row.signature),
|
|
714
|
+
dirty: Boolean(row.dirty),
|
|
715
|
+
eventPending: Boolean(row.event_pending),
|
|
716
|
+
generation: Number(row.generation),
|
|
717
|
+
observedAt: Number(row.observed_at),
|
|
718
|
+
hash: row.hash === null ? null : String(row.hash),
|
|
719
|
+
verifiedAt: Number(row.verified_at),
|
|
720
|
+
seenLocal: Boolean(row.seen_local)
|
|
721
|
+
} : void 0;
|
|
722
|
+
}
|
|
723
|
+
restartWaiting(now) {
|
|
724
|
+
this.db.prepare(`UPDATE ${this.tables.file_index} SET observed_at=? WHERE root=? AND dirty=1`).run(now, this.root);
|
|
725
|
+
}
|
|
726
|
+
event(path, now) {
|
|
727
|
+
this.db.prepare(
|
|
728
|
+
`INSERT INTO ${this.tables.file_index}(root, path, observed_at, event_pending) VALUES (?, ?, ?, 1)
|
|
729
|
+
ON CONFLICT(root,path) DO UPDATE SET dirty=1, event_pending=1, generation=generation+1, observed_at=excluded.observed_at`
|
|
730
|
+
).run(this.root, path, now);
|
|
731
|
+
this.db.prepare(
|
|
732
|
+
`UPDATE ${this.tables.file_index} SET dirty=1, event_pending=1, generation=generation+1, observed_at=? WHERE root=? AND substr(path,1,length(?))=?`
|
|
733
|
+
).run(now, this.root, `${path}/`, `${path}/`);
|
|
734
|
+
}
|
|
735
|
+
observe(path, signature2, now) {
|
|
736
|
+
const prior = this.get(path);
|
|
737
|
+
if (!prior) {
|
|
738
|
+
if (signature2 !== null)
|
|
739
|
+
this.db.prepare(
|
|
740
|
+
`INSERT INTO ${this.tables.file_index}(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)`
|
|
741
|
+
).run(this.root, path, signature2, now);
|
|
742
|
+
} else if (signature2 !== prior.signature) {
|
|
743
|
+
this.db.prepare(
|
|
744
|
+
`UPDATE ${this.tables.file_index} SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?`
|
|
745
|
+
).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
746
|
+
} else if (prior.eventPending) {
|
|
747
|
+
this.db.prepare(`UPDATE ${this.tables.file_index} SET event_pending=0 WHERE root=? AND path=?`).run(this.root, path);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
clean(path, generation, hash2, now) {
|
|
751
|
+
this.db.prepare(
|
|
752
|
+
`UPDATE ${this.tables.file_index} SET dirty=0, hash=?, verified_at=? WHERE root=? AND path=? AND generation=?`
|
|
753
|
+
).run(hash2, now, this.root, path, generation);
|
|
754
|
+
}
|
|
755
|
+
verified(path, now) {
|
|
756
|
+
this.db.prepare(`UPDATE ${this.tables.file_index} SET verified_at=? WHERE root=? AND path=?`).run(now, this.root, path);
|
|
757
|
+
}
|
|
758
|
+
revision(owner) {
|
|
759
|
+
const row = this.db.prepare(`SELECT revision FROM ${this.tables.received_progress} WHERE root=? AND owner=?`).get(this.root, owner);
|
|
760
|
+
return row ? Number(row.revision) : void 0;
|
|
761
|
+
}
|
|
762
|
+
// The author's acknowledged publication is also its last known mirror baseline.
|
|
763
|
+
// Persist this without reapplying bytes: local edits may already be newer.
|
|
764
|
+
acknowledgeOwnFiles(owner, revision, files) {
|
|
765
|
+
if ((this.revision(owner) ?? -1) >= revision || this.job(owner)) return;
|
|
766
|
+
transaction(this.db, () => {
|
|
767
|
+
this.db.prepare(`DELETE FROM ${this.tables.received_files} WHERE root=? AND owner=?`).run(this.root, owner);
|
|
768
|
+
for (const file of files)
|
|
769
|
+
this.db.prepare(`INSERT INTO ${this.tables.received_files} VALUES (?,?,?,?,?)`).run(this.root, owner, file.path, file.sha256, file.size);
|
|
770
|
+
this.db.prepare(
|
|
771
|
+
`INSERT INTO ${this.tables.received_progress} VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision`
|
|
772
|
+
).run(this.root, owner, revision);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
received(owner, path) {
|
|
776
|
+
const row = this.db.prepare(
|
|
777
|
+
`SELECT hash,size FROM ${this.tables.received_files} WHERE root=? AND owner=? AND path=?`
|
|
778
|
+
).get(this.root, owner, path);
|
|
779
|
+
return row ? { sha256: String(row.hash), size: Number(row.size) } : void 0;
|
|
780
|
+
}
|
|
781
|
+
job(owner) {
|
|
782
|
+
const row = this.db.prepare(`SELECT * FROM ${this.tables.receive_jobs} WHERE root=? AND owner=?`).get(this.root, owner);
|
|
783
|
+
return row ? {
|
|
784
|
+
revision: Number(row.revision),
|
|
785
|
+
changes: JSON.parse(String(row.changes_json)),
|
|
786
|
+
nextIndex: Number(row.next_index)
|
|
787
|
+
} : void 0;
|
|
788
|
+
}
|
|
789
|
+
queue(owner, revision, changes) {
|
|
790
|
+
this.db.prepare(
|
|
791
|
+
`INSERT INTO ${this.tables.receive_jobs}(root, owner, revision, changes_json) VALUES (?, ?, ?, ?)`
|
|
792
|
+
).run(this.root, owner, revision, JSON.stringify(changes));
|
|
793
|
+
}
|
|
794
|
+
applied(owner, change, nextIndex) {
|
|
795
|
+
transaction(this.db, () => {
|
|
796
|
+
if (change.kind === "delete")
|
|
797
|
+
this.db.prepare(`DELETE FROM ${this.tables.received_files} WHERE root=? AND owner=? AND path=?`).run(this.root, owner, change.path);
|
|
798
|
+
else
|
|
799
|
+
this.db.prepare(
|
|
800
|
+
`INSERT INTO ${this.tables.received_files} VALUES (?,?,?,?,?) ON CONFLICT(root,owner,path) DO UPDATE SET hash=excluded.hash,size=excluded.size`
|
|
801
|
+
).run(this.root, owner, change.path, change.sha256, change.size);
|
|
802
|
+
this.db.prepare(`UPDATE ${this.tables.receive_jobs} SET next_index=? WHERE root=? AND owner=?`).run(nextIndex, this.root, owner);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
finish(owner) {
|
|
806
|
+
transaction(this.db, () => {
|
|
807
|
+
const job = this.job(owner);
|
|
808
|
+
if (!job || job.nextIndex !== job.changes.length)
|
|
809
|
+
throw new Error("Receive batch is not fully applied.");
|
|
810
|
+
this.db.prepare(
|
|
811
|
+
`INSERT INTO ${this.tables.received_progress} VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision`
|
|
812
|
+
).run(this.root, owner, job.revision);
|
|
813
|
+
this.db.prepare(`DELETE FROM ${this.tables.receive_jobs} WHERE root=? AND owner=?`).run(this.root, owner);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
saveStatus(status) {
|
|
817
|
+
this.db.prepare(
|
|
818
|
+
`INSERT INTO ${this.tables.sync_status} VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json`
|
|
819
|
+
).run(this.root, JSON.stringify(status));
|
|
820
|
+
}
|
|
821
|
+
status() {
|
|
822
|
+
const row = this.db.prepare(`SELECT status_json FROM ${this.tables.sync_status} WHERE root=?`).get(this.root);
|
|
823
|
+
return row ? JSON.parse(String(row.status_json)) : null;
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// apps/client/files/project-schema.ts
|
|
828
|
+
var projectFilesClientMigration = `
|
|
829
|
+
CREATE TABLE project_publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
|
|
830
|
+
CREATE TABLE project_owner_files (root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, path));
|
|
831
|
+
CREATE TABLE project_outbox (root TEXT PRIMARY KEY REFERENCES connections(root), request_json TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, last_error TEXT);
|
|
832
|
+
${syncMigration.replace(/\b(file_index|dirty_files|audit_files|received_files|received_progress|receive_jobs|sync_status)\b/g, "project_$1")}
|
|
833
|
+
ALTER TABLE project_file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
|
|
834
|
+
CREATE TABLE project_file_settings (root TEXT PRIMARY KEY REFERENCES connections(root), config_json TEXT NOT NULL);
|
|
835
|
+
`;
|
|
836
|
+
|
|
837
|
+
// apps/client/runtime/state.ts
|
|
838
|
+
var runtimeMigration = `
|
|
839
|
+
ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
|
|
840
|
+
UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
|
|
841
|
+
CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
|
|
842
|
+
CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
|
|
843
|
+
`;
|
|
844
|
+
var RuntimeState = class {
|
|
845
|
+
constructor(state) {
|
|
846
|
+
this.state = state;
|
|
847
|
+
}
|
|
848
|
+
state;
|
|
849
|
+
enabled(root) {
|
|
850
|
+
return Boolean(
|
|
851
|
+
this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
enable(root, enabled) {
|
|
855
|
+
if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
|
|
856
|
+
this.state.db.prepare(
|
|
857
|
+
"INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
|
|
858
|
+
).run(root, Number(enabled));
|
|
859
|
+
}
|
|
860
|
+
roots() {
|
|
861
|
+
return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
|
|
862
|
+
}
|
|
863
|
+
status(root) {
|
|
864
|
+
const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
|
|
865
|
+
return row?.status_json ? JSON.parse(String(row.status_json)) : null;
|
|
866
|
+
}
|
|
867
|
+
save(root, status) {
|
|
868
|
+
this.state.db.prepare(
|
|
869
|
+
"INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
870
|
+
).run(root, JSON.stringify(status));
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// apps/client/state.ts
|
|
875
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
876
|
+
import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
|
|
877
|
+
import { z as z9 } from "zod";
|
|
878
|
+
|
|
574
879
|
// apps/client/workspace.ts
|
|
575
880
|
import { execFile } from "node:child_process";
|
|
576
881
|
import { promisify } from "node:util";
|
|
@@ -591,7 +896,7 @@ function signature(stat2) {
|
|
|
591
896
|
}
|
|
592
897
|
function ignored(path) {
|
|
593
898
|
return path.split("/").some(
|
|
594
|
-
(part) => part === ".git" || part === ".DS_Store" || /^\.teammy(
|
|
899
|
+
(part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:[-.]|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
|
|
595
900
|
);
|
|
596
901
|
}
|
|
597
902
|
async function safePath(root, path, createParents = false) {
|
|
@@ -626,7 +931,7 @@ async function metadata(root, path) {
|
|
|
626
931
|
throw error;
|
|
627
932
|
}
|
|
628
933
|
}
|
|
629
|
-
async function walk(root, prefix = "") {
|
|
934
|
+
async function walk(root, prefix = "", excludes = ignored) {
|
|
630
935
|
const files = /* @__PURE__ */ new Map();
|
|
631
936
|
const issues = [];
|
|
632
937
|
const directory = prefix ? await safePath(root, prefix) : root;
|
|
@@ -644,7 +949,7 @@ async function walk(root, prefix = "") {
|
|
|
644
949
|
throw new Error(`Directory changed while scanning: ${current}`);
|
|
645
950
|
for (const entry of await readdir(full, { withFileTypes: true })) {
|
|
646
951
|
const path = current ? `${current}/${entry.name}` : entry.name;
|
|
647
|
-
if (
|
|
952
|
+
if (excludes(path, entry.isDirectory())) continue;
|
|
648
953
|
try {
|
|
649
954
|
FilePathSchema.parse(path);
|
|
650
955
|
const stat2 = await lstat(await safePath(root, path), { bigint: true });
|
|
@@ -846,8 +1151,8 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
|
|
|
846
1151
|
}
|
|
847
1152
|
if (prior && !prior.complete && prior.input_json === JSON.stringify(input)) {
|
|
848
1153
|
await verifyWorkspace(state, root);
|
|
849
|
-
const
|
|
850
|
-
if (
|
|
1154
|
+
const ignore3 = await ignorePlan(root);
|
|
1155
|
+
if (ignore3) await replaceFile(ignore3.parent, ".gitignore", ignore3.bytes, ignore3.signature);
|
|
851
1156
|
return;
|
|
852
1157
|
}
|
|
853
1158
|
throw new Error(
|
|
@@ -856,7 +1161,7 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
|
|
|
856
1161
|
}
|
|
857
1162
|
if (state.binding(root) || prior)
|
|
858
1163
|
throw new Error("Connected workspace is missing. Restore its directory before syncing.");
|
|
859
|
-
const
|
|
1164
|
+
const ignore2 = await ignorePlan(root);
|
|
860
1165
|
await mkdir2(root);
|
|
861
1166
|
try {
|
|
862
1167
|
const identity = await directoryIdentity(root);
|
|
@@ -867,7 +1172,7 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
|
|
|
867
1172
|
});
|
|
868
1173
|
throw e;
|
|
869
1174
|
}
|
|
870
|
-
if (
|
|
1175
|
+
if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
|
|
871
1176
|
}
|
|
872
1177
|
function completePreparation(state, root) {
|
|
873
1178
|
state.db.prepare("UPDATE client_preparations SET complete=1 WHERE root=?").run(root);
|
|
@@ -950,43 +1255,43 @@ var ClientLog = class {
|
|
|
950
1255
|
import { setTimeout as setTimeout2 } from "node:timers/promises";
|
|
951
1256
|
|
|
952
1257
|
// packages/protocol/devices.ts
|
|
953
|
-
import { z as
|
|
954
|
-
var CredentialSchema =
|
|
955
|
-
var EnrollmentSchema =
|
|
956
|
-
operationId:
|
|
1258
|
+
import { z as z8 } from "zod";
|
|
1259
|
+
var CredentialSchema = z8.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
1260
|
+
var EnrollmentSchema = z8.object({
|
|
1261
|
+
operationId: z8.uuid(),
|
|
957
1262
|
projectId: NameSchema,
|
|
958
1263
|
memberId: NameSchema,
|
|
959
1264
|
deviceId: DeviceIdSchema,
|
|
960
1265
|
credential: CredentialSchema
|
|
961
1266
|
}).strict();
|
|
962
|
-
var EffectivePolicySchema =
|
|
963
|
-
checkIntervalSeconds:
|
|
964
|
-
fileSettleSeconds:
|
|
1267
|
+
var EffectivePolicySchema = z8.object({
|
|
1268
|
+
checkIntervalSeconds: z8.number().min(1).max(86400),
|
|
1269
|
+
fileSettleSeconds: z8.number().min(0).max(86400)
|
|
965
1270
|
}).strict();
|
|
966
|
-
var SyncSummarySchema =
|
|
967
|
-
checkedAt:
|
|
968
|
-
published:
|
|
969
|
-
appliedFiles:
|
|
970
|
-
pendingFiles:
|
|
971
|
-
sessionSources:
|
|
972
|
-
errors:
|
|
1271
|
+
var SyncSummarySchema = z8.object({
|
|
1272
|
+
checkedAt: z8.string(),
|
|
1273
|
+
published: z8.boolean(),
|
|
1274
|
+
appliedFiles: z8.number().int().nonnegative(),
|
|
1275
|
+
pendingFiles: z8.number().int().nonnegative(),
|
|
1276
|
+
sessionSources: z8.number().int().nonnegative(),
|
|
1277
|
+
errors: z8.array(z8.string().max(1e3)).max(20)
|
|
973
1278
|
}).strict();
|
|
974
|
-
var DeviceStatusSchema =
|
|
975
|
-
clientVersion:
|
|
1279
|
+
var DeviceStatusSchema = z8.object({
|
|
1280
|
+
clientVersion: z8.string().max(64),
|
|
976
1281
|
receivedRevision: RevisionSchema.nullable(),
|
|
977
1282
|
appliedRevision: RevisionSchema.nullable(),
|
|
978
1283
|
effectivePolicy: EffectivePolicySchema,
|
|
979
|
-
overrides:
|
|
980
|
-
configError:
|
|
981
|
-
reportError:
|
|
1284
|
+
overrides: z8.array(z8.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
1285
|
+
configError: z8.string().max(2e3).nullable(),
|
|
1286
|
+
reportError: z8.string().max(2e3).nullable(),
|
|
982
1287
|
lastSync: SyncSummarySchema.nullable()
|
|
983
1288
|
}).strict();
|
|
984
|
-
var WebDevicesSchema =
|
|
985
|
-
devices:
|
|
986
|
-
|
|
1289
|
+
var WebDevicesSchema = z8.object({
|
|
1290
|
+
devices: z8.array(
|
|
1291
|
+
z8.object({
|
|
987
1292
|
memberId: NameSchema,
|
|
988
|
-
bound:
|
|
989
|
-
lastSeenAt:
|
|
1293
|
+
bound: z8.boolean(),
|
|
1294
|
+
lastSeenAt: z8.string().nullable(),
|
|
990
1295
|
status: DeviceStatusSchema.nullable()
|
|
991
1296
|
})
|
|
992
1297
|
)
|
|
@@ -1297,157 +1602,15 @@ var SessionState = class {
|
|
|
1297
1602
|
}
|
|
1298
1603
|
};
|
|
1299
1604
|
|
|
1300
|
-
// apps/client/files/state.ts
|
|
1301
|
-
var syncMigration = String.raw`
|
|
1302
|
-
CREATE TABLE file_index (
|
|
1303
|
-
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT, dirty INTEGER NOT NULL DEFAULT 1,
|
|
1304
|
-
event_pending INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 1,
|
|
1305
|
-
observed_at REAL NOT NULL, hash TEXT, verified_at REAL NOT NULL DEFAULT 0,
|
|
1306
|
-
PRIMARY KEY(root, path)
|
|
1307
|
-
);
|
|
1308
|
-
CREATE INDEX dirty_files ON file_index(root, dirty, observed_at);
|
|
1309
|
-
CREATE INDEX audit_files ON file_index(root, dirty, verified_at);
|
|
1310
|
-
CREATE TABLE received_files (root TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, owner, path));
|
|
1311
|
-
CREATE TABLE received_progress (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(root, owner));
|
|
1312
|
-
CREATE TABLE receive_jobs (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, changes_json TEXT NOT NULL, next_index INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root, owner));
|
|
1313
|
-
CREATE TABLE sync_status (root TEXT PRIMARY KEY, status_json TEXT NOT NULL);
|
|
1314
|
-
`;
|
|
1315
|
-
var SyncState = class {
|
|
1316
|
-
constructor(state, root) {
|
|
1317
|
-
this.root = root;
|
|
1318
|
-
this.db = state.db;
|
|
1319
|
-
}
|
|
1320
|
-
root;
|
|
1321
|
-
db;
|
|
1322
|
-
index(onlyDirty = false) {
|
|
1323
|
-
return this.db.prepare(`SELECT * FROM file_index WHERE root=?${onlyDirty ? " AND dirty=1" : ""}`).all(this.root).map((row) => ({
|
|
1324
|
-
path: String(row.path),
|
|
1325
|
-
signature: row.signature === null ? null : String(row.signature),
|
|
1326
|
-
dirty: Boolean(row.dirty),
|
|
1327
|
-
eventPending: Boolean(row.event_pending),
|
|
1328
|
-
generation: Number(row.generation),
|
|
1329
|
-
observedAt: Number(row.observed_at),
|
|
1330
|
-
hash: row.hash === null ? null : String(row.hash),
|
|
1331
|
-
verifiedAt: Number(row.verified_at),
|
|
1332
|
-
seenLocal: Boolean(row.seen_local)
|
|
1333
|
-
}));
|
|
1334
|
-
}
|
|
1335
|
-
audit(before, limit) {
|
|
1336
|
-
return this.db.prepare(
|
|
1337
|
-
"SELECT path FROM file_index WHERE root=? AND dirty=0 AND signature IS NOT NULL AND verified_at<=? ORDER BY verified_at LIMIT ?"
|
|
1338
|
-
).all(this.root, before, limit).map((row) => this.get(String(row.path)));
|
|
1339
|
-
}
|
|
1340
|
-
get(path) {
|
|
1341
|
-
const row = this.db.prepare("SELECT * FROM file_index WHERE root=? AND path=?").get(this.root, path);
|
|
1342
|
-
return row ? {
|
|
1343
|
-
path,
|
|
1344
|
-
signature: row.signature === null ? null : String(row.signature),
|
|
1345
|
-
dirty: Boolean(row.dirty),
|
|
1346
|
-
eventPending: Boolean(row.event_pending),
|
|
1347
|
-
generation: Number(row.generation),
|
|
1348
|
-
observedAt: Number(row.observed_at),
|
|
1349
|
-
hash: row.hash === null ? null : String(row.hash),
|
|
1350
|
-
verifiedAt: Number(row.verified_at),
|
|
1351
|
-
seenLocal: Boolean(row.seen_local)
|
|
1352
|
-
} : void 0;
|
|
1353
|
-
}
|
|
1354
|
-
restartWaiting(now) {
|
|
1355
|
-
this.db.prepare("UPDATE file_index SET observed_at=? WHERE root=? AND dirty=1").run(now, this.root);
|
|
1356
|
-
}
|
|
1357
|
-
event(path, now) {
|
|
1358
|
-
this.db.prepare(
|
|
1359
|
-
`INSERT INTO file_index(root, path, observed_at, event_pending) VALUES (?, ?, ?, 1)
|
|
1360
|
-
ON CONFLICT(root,path) DO UPDATE SET dirty=1, event_pending=1, generation=generation+1, observed_at=excluded.observed_at`
|
|
1361
|
-
).run(this.root, path, now);
|
|
1362
|
-
this.db.prepare(
|
|
1363
|
-
"UPDATE file_index SET dirty=1, event_pending=1, generation=generation+1, observed_at=? WHERE root=? AND substr(path,1,length(?))=?"
|
|
1364
|
-
).run(now, this.root, `${path}/`, `${path}/`);
|
|
1365
|
-
}
|
|
1366
|
-
observe(path, signature2, now) {
|
|
1367
|
-
const prior = this.get(path);
|
|
1368
|
-
if (!prior) {
|
|
1369
|
-
if (signature2 !== null)
|
|
1370
|
-
this.db.prepare(
|
|
1371
|
-
"INSERT INTO file_index(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)"
|
|
1372
|
-
).run(this.root, path, signature2, now);
|
|
1373
|
-
} else if (signature2 !== prior.signature) {
|
|
1374
|
-
this.db.prepare(
|
|
1375
|
-
"UPDATE file_index SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1376
|
-
).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1377
|
-
} else if (prior.eventPending) {
|
|
1378
|
-
this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
clean(path, generation, hash2, now) {
|
|
1382
|
-
this.db.prepare(
|
|
1383
|
-
"UPDATE file_index SET dirty=0, hash=?, verified_at=? WHERE root=? AND path=? AND generation=?"
|
|
1384
|
-
).run(hash2, now, this.root, path, generation);
|
|
1385
|
-
}
|
|
1386
|
-
verified(path, now) {
|
|
1387
|
-
this.db.prepare("UPDATE file_index SET verified_at=? WHERE root=? AND path=?").run(now, this.root, path);
|
|
1388
|
-
}
|
|
1389
|
-
revision(owner) {
|
|
1390
|
-
const row = this.db.prepare("SELECT revision FROM received_progress WHERE root=? AND owner=?").get(this.root, owner);
|
|
1391
|
-
return row ? Number(row.revision) : void 0;
|
|
1392
|
-
}
|
|
1393
|
-
received(owner, path) {
|
|
1394
|
-
const row = this.db.prepare("SELECT hash,size FROM received_files WHERE root=? AND owner=? AND path=?").get(this.root, owner, path);
|
|
1395
|
-
return row ? { sha256: String(row.hash), size: Number(row.size) } : void 0;
|
|
1396
|
-
}
|
|
1397
|
-
job(owner) {
|
|
1398
|
-
const row = this.db.prepare("SELECT * FROM receive_jobs WHERE root=? AND owner=?").get(this.root, owner);
|
|
1399
|
-
return row ? {
|
|
1400
|
-
revision: Number(row.revision),
|
|
1401
|
-
changes: JSON.parse(String(row.changes_json)),
|
|
1402
|
-
nextIndex: Number(row.next_index)
|
|
1403
|
-
} : void 0;
|
|
1404
|
-
}
|
|
1405
|
-
queue(owner, revision, changes) {
|
|
1406
|
-
this.db.prepare("INSERT INTO receive_jobs(root, owner, revision, changes_json) VALUES (?, ?, ?, ?)").run(this.root, owner, revision, JSON.stringify(changes));
|
|
1407
|
-
}
|
|
1408
|
-
applied(owner, change, nextIndex) {
|
|
1409
|
-
transaction(this.db, () => {
|
|
1410
|
-
if (change.kind === "delete")
|
|
1411
|
-
this.db.prepare("DELETE FROM received_files WHERE root=? AND owner=? AND path=?").run(this.root, owner, change.path);
|
|
1412
|
-
else
|
|
1413
|
-
this.db.prepare(
|
|
1414
|
-
"INSERT INTO received_files VALUES (?,?,?,?,?) ON CONFLICT(root,owner,path) DO UPDATE SET hash=excluded.hash,size=excluded.size"
|
|
1415
|
-
).run(this.root, owner, change.path, change.sha256, change.size);
|
|
1416
|
-
this.db.prepare("UPDATE receive_jobs SET next_index=? WHERE root=? AND owner=?").run(nextIndex, this.root, owner);
|
|
1417
|
-
});
|
|
1418
|
-
}
|
|
1419
|
-
finish(owner) {
|
|
1420
|
-
transaction(this.db, () => {
|
|
1421
|
-
const job = this.job(owner);
|
|
1422
|
-
if (!job || job.nextIndex !== job.changes.length)
|
|
1423
|
-
throw new Error("Receive batch is not fully applied.");
|
|
1424
|
-
this.db.prepare(
|
|
1425
|
-
"INSERT INTO received_progress VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision"
|
|
1426
|
-
).run(this.root, owner, job.revision);
|
|
1427
|
-
this.db.prepare("DELETE FROM receive_jobs WHERE root=? AND owner=?").run(this.root, owner);
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
saveStatus(status) {
|
|
1431
|
-
this.db.prepare(
|
|
1432
|
-
"INSERT INTO sync_status VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
1433
|
-
).run(this.root, JSON.stringify(status));
|
|
1434
|
-
}
|
|
1435
|
-
status() {
|
|
1436
|
-
const row = this.db.prepare("SELECT status_json FROM sync_status WHERE root=?").get(this.root);
|
|
1437
|
-
return row ? JSON.parse(String(row.status_json)) : null;
|
|
1438
|
-
}
|
|
1439
|
-
};
|
|
1440
|
-
|
|
1441
1605
|
// apps/client/state.ts
|
|
1442
1606
|
var LocalBindingSchema = BindingSchema.extend({
|
|
1443
|
-
root:
|
|
1444
|
-
serverUrl:
|
|
1445
|
-
token:
|
|
1607
|
+
root: z9.string(),
|
|
1608
|
+
serverUrl: z9.string(),
|
|
1609
|
+
token: z9.string()
|
|
1446
1610
|
});
|
|
1447
|
-
var ClientState = class {
|
|
1448
|
-
db;
|
|
1611
|
+
var ClientState = class extends FilePublicationState {
|
|
1449
1612
|
constructor(directory) {
|
|
1450
|
-
|
|
1613
|
+
const db = openDatabase(join4(directory, "client.sqlite3"), [
|
|
1451
1614
|
String.raw`
|
|
1452
1615
|
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1453
1616
|
`,
|
|
@@ -1460,8 +1623,10 @@ var ClientState = class {
|
|
|
1460
1623
|
sessionClientMigration,
|
|
1461
1624
|
projectClientMigration,
|
|
1462
1625
|
controlClientMigration,
|
|
1463
|
-
runtimeMigration
|
|
1626
|
+
runtimeMigration,
|
|
1627
|
+
projectFilesClientMigration
|
|
1464
1628
|
]);
|
|
1629
|
+
super(db);
|
|
1465
1630
|
}
|
|
1466
1631
|
close() {
|
|
1467
1632
|
this.db.close();
|
|
@@ -1503,73 +1668,6 @@ var ClientState = class {
|
|
|
1503
1668
|
this.db.prepare("UPDATE connections SET binding_json=? WHERE root=?").run(JSON.stringify(local), root);
|
|
1504
1669
|
return local;
|
|
1505
1670
|
}
|
|
1506
|
-
publication(root) {
|
|
1507
|
-
this.db.prepare("INSERT OR IGNORE INTO publications(root) VALUES (?)").run(root);
|
|
1508
|
-
const row = this.db.prepare("SELECT revision, initialized FROM publications WHERE root=?").get(root);
|
|
1509
|
-
return { revision: Number(row.revision), initialized: Boolean(row.initialized) };
|
|
1510
|
-
}
|
|
1511
|
-
initializePublication(root, revision, files) {
|
|
1512
|
-
transaction(this.db, () => {
|
|
1513
|
-
if (this.pending(root))
|
|
1514
|
-
throw new Error("Cannot replace a baseline with a publication pending.");
|
|
1515
|
-
this.db.prepare(
|
|
1516
|
-
"INSERT INTO publications VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1"
|
|
1517
|
-
).run(root, revision);
|
|
1518
|
-
this.db.prepare("DELETE FROM owner_files WHERE root=?").run(root);
|
|
1519
|
-
for (const file of files)
|
|
1520
|
-
this.db.prepare("INSERT INTO owner_files VALUES (?, ?, ?, ?)").run(root, file.path, file.sha256, file.size);
|
|
1521
|
-
});
|
|
1522
|
-
}
|
|
1523
|
-
ownerFiles(root) {
|
|
1524
|
-
return new Map(
|
|
1525
|
-
this.db.prepare("SELECT path, hash, size FROM owner_files WHERE root=?").all(root).map((row) => [String(row.path), { sha256: String(row.hash), size: Number(row.size) }])
|
|
1526
|
-
);
|
|
1527
|
-
}
|
|
1528
|
-
pending(root) {
|
|
1529
|
-
const row = this.db.prepare("SELECT request_json, attempts, retry_at, last_error FROM outbox WHERE root=?").get(root);
|
|
1530
|
-
return row ? {
|
|
1531
|
-
batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
|
|
1532
|
-
attempts: Number(row.attempts),
|
|
1533
|
-
retryAt: Number(row.retry_at),
|
|
1534
|
-
lastError: row.last_error === null ? null : String(row.last_error)
|
|
1535
|
-
} : void 0;
|
|
1536
|
-
}
|
|
1537
|
-
enqueue(root, batch) {
|
|
1538
|
-
const fixed = FileCommitSchema.parse(batch);
|
|
1539
|
-
transaction(this.db, () => {
|
|
1540
|
-
const publication = this.publication(root);
|
|
1541
|
-
if (!publication.initialized || publication.revision !== fixed.parentRevision)
|
|
1542
|
-
throw new Error("Publication baseline is not ready.");
|
|
1543
|
-
if (this.pending(root)) throw new Error("A publication is already pending.");
|
|
1544
|
-
this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
acknowledge(root, result2) {
|
|
1548
|
-
transaction(this.db, () => {
|
|
1549
|
-
const pending = this.pending(root);
|
|
1550
|
-
if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
|
|
1551
|
-
throw new Error("Unexpected publication acknowledgement.");
|
|
1552
|
-
for (const change of pending.batch.changes) {
|
|
1553
|
-
if (change.kind === "delete")
|
|
1554
|
-
this.db.prepare("DELETE FROM owner_files WHERE root=? AND path=?").run(root, change.path);
|
|
1555
|
-
else
|
|
1556
|
-
this.db.prepare(
|
|
1557
|
-
"INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
|
|
1558
|
-
).run(root, change.path, change.sha256, change.size);
|
|
1559
|
-
}
|
|
1560
|
-
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result2.revision, root);
|
|
1561
|
-
this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
postpone(root, operationId, reason, now, blocked) {
|
|
1565
|
-
const pending = this.pending(root);
|
|
1566
|
-
if (!pending || pending.batch.operationId !== operationId) return;
|
|
1567
|
-
const retryAt = blocked ? Number.MAX_SAFE_INTEGER : now + Math.min(9e5, 6e4 * 2 ** Math.min(pending.attempts, 4));
|
|
1568
|
-
this.db.prepare("UPDATE outbox SET attempts=attempts+1, retry_at=?, last_error=? WHERE root=?").run(retryAt, reason, root);
|
|
1569
|
-
}
|
|
1570
|
-
retryPublication(root) {
|
|
1571
|
-
this.db.prepare("UPDATE outbox SET retry_at=0, last_error=NULL WHERE root=?").run(root);
|
|
1572
|
-
}
|
|
1573
1671
|
};
|
|
1574
1672
|
|
|
1575
1673
|
// apps/client/state-location.ts
|
|
@@ -1593,7 +1691,7 @@ async function stateLocation(root, directory) {
|
|
|
1593
1691
|
import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
|
|
1594
1692
|
import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
1595
1693
|
import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
|
|
1596
|
-
import { z as
|
|
1694
|
+
import { z as z10 } from "zod";
|
|
1597
1695
|
|
|
1598
1696
|
// apps/client/sessions/normalize.ts
|
|
1599
1697
|
function object(value) {
|
|
@@ -1711,10 +1809,10 @@ function normalizeJsonl(agent, text) {
|
|
|
1711
1809
|
}
|
|
1712
1810
|
|
|
1713
1811
|
// apps/client/sessions/hooks.ts
|
|
1714
|
-
var HintSchema =
|
|
1812
|
+
var HintSchema = z10.object({
|
|
1715
1813
|
agent: AgentSchema,
|
|
1716
|
-
path:
|
|
1717
|
-
projectDir:
|
|
1814
|
+
path: z10.string().min(1).max(4096).refine(isAbsolute3),
|
|
1815
|
+
projectDir: z10.string().min(1).max(4096).refine(isAbsolute3)
|
|
1718
1816
|
});
|
|
1719
1817
|
function inbox(directory, root) {
|
|
1720
1818
|
return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
|
|
@@ -1927,7 +2025,7 @@ import { setTimeout as setTimeout4 } from "node:timers/promises";
|
|
|
1927
2025
|
|
|
1928
2026
|
// apps/client/client-command.ts
|
|
1929
2027
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
1930
|
-
import { join as
|
|
2028
|
+
import { join as join11, resolve as resolve5 } from "node:path";
|
|
1931
2029
|
import { homedir as homedir3 } from "node:os";
|
|
1932
2030
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1933
2031
|
|
|
@@ -1956,6 +2054,64 @@ function acquireRootLock(root, stateDirectory) {
|
|
|
1956
2054
|
}
|
|
1957
2055
|
}
|
|
1958
2056
|
|
|
2057
|
+
// apps/client/local-config.ts
|
|
2058
|
+
import { constants as constants2 } from "node:fs";
|
|
2059
|
+
import { open as open3, writeFile } from "node:fs/promises";
|
|
2060
|
+
import { join as join8 } from "node:path";
|
|
2061
|
+
import { z as z11 } from "zod";
|
|
2062
|
+
import ignore from "ignore";
|
|
2063
|
+
var LOCAL_CONFIG_NAME = ".teammy-local.json";
|
|
2064
|
+
var LocalConfigSchema = z11.object({
|
|
2065
|
+
files: z11.object({ ignore: z11.array(z11.string().max(1024)).max(256).default([]) }).strict().default({ ignore: [] })
|
|
2066
|
+
}).strict();
|
|
2067
|
+
async function ensureLocalConfig(root) {
|
|
2068
|
+
try {
|
|
2069
|
+
await writeFile(
|
|
2070
|
+
join8(root, LOCAL_CONFIG_NAME),
|
|
2071
|
+
JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
|
|
2072
|
+
{ flag: "wx", mode: 384 }
|
|
2073
|
+
);
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
async function loadLocalRules(root) {
|
|
2079
|
+
let rules = [];
|
|
2080
|
+
try {
|
|
2081
|
+
const file = await open3(
|
|
2082
|
+
join8(root, LOCAL_CONFIG_NAME),
|
|
2083
|
+
constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
|
|
2084
|
+
);
|
|
2085
|
+
try {
|
|
2086
|
+
const before = await file.stat();
|
|
2087
|
+
if (!before.isFile() || before.size > 65536)
|
|
2088
|
+
throw new Error("Expected a regular configuration file no larger than 64 KiB.");
|
|
2089
|
+
const buffer = Buffer.alloc(65537);
|
|
2090
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
2091
|
+
if (bytesRead > 65536 || bytesRead !== before.size)
|
|
2092
|
+
throw new Error("Configuration exceeds the read limit or changed while reading.");
|
|
2093
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
|
|
2094
|
+
const after = await file.stat();
|
|
2095
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
2096
|
+
throw new Error("Configuration is being saved; retry on the next check.");
|
|
2097
|
+
rules = LocalConfigSchema.parse(JSON.parse(text)).files.ignore;
|
|
2098
|
+
} finally {
|
|
2099
|
+
await file.close();
|
|
2100
|
+
}
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
if (!missing(error))
|
|
2103
|
+
throw new Error(
|
|
2104
|
+
`${LOCAL_CONFIG_NAME}: invalid or unreadable configuration; uploads paused.`,
|
|
2105
|
+
{ cause: error }
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
const matcher = ignore().add(rules);
|
|
2109
|
+
return {
|
|
2110
|
+
fingerprint: JSON.stringify(rules),
|
|
2111
|
+
excludes: (path, directory = false) => ignored(path) || matcher.ignores(path + (directory && !path.endsWith("/") ? "/" : ""))
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
|
|
1959
2115
|
// apps/client/connection.ts
|
|
1960
2116
|
async function connectProject(state, root, directory, options) {
|
|
1961
2117
|
const lock = acquireRootLock(root, directory);
|
|
@@ -2030,6 +2186,7 @@ async function connectProject(state, root, directory, options) {
|
|
|
2030
2186
|
const own = await metadata(root, binding.memberId);
|
|
2031
2187
|
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
2032
2188
|
if (!own) await mkdir5(await safePath(root, binding.memberId));
|
|
2189
|
+
await ensureLocalConfig(root);
|
|
2033
2190
|
const { token: _, ...visible } = binding;
|
|
2034
2191
|
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
2035
2192
|
} finally {
|
|
@@ -2040,21 +2197,21 @@ async function connectProject(state, root, directory, options) {
|
|
|
2040
2197
|
// apps/client/runtime/ipc.ts
|
|
2041
2198
|
import { createConnection } from "node:net";
|
|
2042
2199
|
import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
|
|
2043
|
-
import { join as
|
|
2044
|
-
import { z as
|
|
2045
|
-
var RequestSchema =
|
|
2046
|
-
method:
|
|
2047
|
-
root:
|
|
2200
|
+
import { join as join9 } from "node:path";
|
|
2201
|
+
import { z as z12 } from "zod";
|
|
2202
|
+
var RequestSchema = z12.object({
|
|
2203
|
+
method: z12.enum(["ping", "wake", "status", "once"]),
|
|
2204
|
+
root: z12.string().max(4096).optional()
|
|
2048
2205
|
}).strict();
|
|
2049
2206
|
function socketPath(directory) {
|
|
2050
|
-
return
|
|
2207
|
+
return join9(
|
|
2051
2208
|
"/tmp",
|
|
2052
2209
|
`teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
|
|
2053
2210
|
"control.sock"
|
|
2054
2211
|
);
|
|
2055
2212
|
}
|
|
2056
2213
|
async function prepareSocketDirectory(directory) {
|
|
2057
|
-
const path =
|
|
2214
|
+
const path = join9(socketPath(directory), "..");
|
|
2058
2215
|
await mkdir6(path, { recursive: true, mode: 448 });
|
|
2059
2216
|
const stat2 = await lstat3(path);
|
|
2060
2217
|
if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
|
|
@@ -2117,8 +2274,8 @@ async function runningDaemon(directory) {
|
|
|
2117
2274
|
import { execFile as execFile2 } from "node:child_process";
|
|
2118
2275
|
import { promisify as promisify2 } from "node:util";
|
|
2119
2276
|
import { homedir as homedir2 } from "node:os";
|
|
2120
|
-
import { dirname as dirname5, join as
|
|
2121
|
-
import { mkdir as mkdir7, readFile as readFile2, writeFile, rename as rename3 } from "node:fs/promises";
|
|
2277
|
+
import { dirname as dirname5, join as join10, resolve as resolve4 } from "node:path";
|
|
2278
|
+
import { mkdir as mkdir7, readFile as readFile2, writeFile as writeFile2, rename as rename3 } from "node:fs/promises";
|
|
2122
2279
|
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2123
2280
|
var execute = promisify2(execFile2);
|
|
2124
2281
|
var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
@@ -2135,7 +2292,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
|
|
|
2135
2292
|
if (platform === "darwin")
|
|
2136
2293
|
return {
|
|
2137
2294
|
id,
|
|
2138
|
-
path:
|
|
2295
|
+
path: join10(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2139
2296
|
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2140
2297
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2141
2298
|
<plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
|
|
@@ -2144,7 +2301,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
|
|
|
2144
2301
|
if (platform === "linux")
|
|
2145
2302
|
return {
|
|
2146
2303
|
id: id + ".service",
|
|
2147
|
-
path:
|
|
2304
|
+
path: join10(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2148
2305
|
content: `[Unit]
|
|
2149
2306
|
Description=Teammy background synchronization
|
|
2150
2307
|
[Service]
|
|
@@ -2203,7 +2360,7 @@ async function ensureDaemon(directory, version) {
|
|
|
2203
2360
|
await mkdir7(dirname5(definition.path), { recursive: true });
|
|
2204
2361
|
if (old !== definition.content) {
|
|
2205
2362
|
const temp = definition.path + `.${process.pid}.tmp`;
|
|
2206
|
-
await
|
|
2363
|
+
await writeFile2(temp, definition.content, { mode: 384 });
|
|
2207
2364
|
await rename3(temp, definition.path);
|
|
2208
2365
|
}
|
|
2209
2366
|
if (process.platform === "darwin") {
|
|
@@ -2263,7 +2420,7 @@ function json(value) {
|
|
|
2263
2420
|
// apps/client/client-command.ts
|
|
2264
2421
|
var locationOptions = {
|
|
2265
2422
|
root: { type: "string", default: resolve5("workspaces") },
|
|
2266
|
-
"state-dir": { type: "string", default:
|
|
2423
|
+
"state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") },
|
|
2267
2424
|
json: { type: "boolean" },
|
|
2268
2425
|
help: { type: "boolean", short: "h" }
|
|
2269
2426
|
};
|
|
@@ -2385,13 +2542,13 @@ async function runClientCommand(command, args, version) {
|
|
|
2385
2542
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2386
2543
|
import { watch } from "node:fs";
|
|
2387
2544
|
import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
|
|
2388
|
-
import { join as
|
|
2545
|
+
import { join as join13, sep as sep4 } from "node:path";
|
|
2389
2546
|
import { performance } from "node:perf_hooks";
|
|
2390
2547
|
|
|
2391
2548
|
// packages/storage/local-blob-store.ts
|
|
2392
2549
|
import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
|
|
2393
|
-
import { link, mkdir as mkdir8, open as
|
|
2394
|
-
import { dirname as dirname6, join as
|
|
2550
|
+
import { link, mkdir as mkdir8, open as open4, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
|
|
2551
|
+
import { dirname as dirname6, join as join12, resolve as resolve6 } from "node:path";
|
|
2395
2552
|
function hash(content) {
|
|
2396
2553
|
return createHash8("sha256").update(content).digest("hex");
|
|
2397
2554
|
}
|
|
@@ -2417,7 +2574,7 @@ var LocalBlobStore = class {
|
|
|
2417
2574
|
const target = this.pathFor(sha256);
|
|
2418
2575
|
await mkdir8(dirname6(target), { recursive: true, mode: 448 });
|
|
2419
2576
|
const temporary = `${target}.${randomUUID5()}.tmp`;
|
|
2420
|
-
const file = await
|
|
2577
|
+
const file = await open4(temporary, "wx", 384);
|
|
2421
2578
|
try {
|
|
2422
2579
|
try {
|
|
2423
2580
|
await file.writeFile(bytes);
|
|
@@ -2434,7 +2591,7 @@ var LocalBlobStore = class {
|
|
|
2434
2591
|
if (process.platform !== "win32") {
|
|
2435
2592
|
let directory = dirname6(target);
|
|
2436
2593
|
for (; ; ) {
|
|
2437
|
-
const handle = await
|
|
2594
|
+
const handle = await open4(directory, "r");
|
|
2438
2595
|
try {
|
|
2439
2596
|
await handle.sync();
|
|
2440
2597
|
} finally {
|
|
@@ -2463,7 +2620,7 @@ var LocalBlobStore = class {
|
|
|
2463
2620
|
}
|
|
2464
2621
|
pathFor(sha256) {
|
|
2465
2622
|
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
2466
|
-
return
|
|
2623
|
+
return join12(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
2467
2624
|
}
|
|
2468
2625
|
};
|
|
2469
2626
|
|
|
@@ -2514,7 +2671,7 @@ var PublicationQueue = class {
|
|
|
2514
2671
|
};
|
|
2515
2672
|
|
|
2516
2673
|
// apps/client/files/synchronizer.ts
|
|
2517
|
-
var FileSynchronizer = class {
|
|
2674
|
+
var FileSynchronizer = class _FileSynchronizer {
|
|
2518
2675
|
constructor(state, root, stateDirectory, options = {}) {
|
|
2519
2676
|
this.state = state;
|
|
2520
2677
|
this.root = root;
|
|
@@ -2523,10 +2680,18 @@ var FileSynchronizer = class {
|
|
|
2523
2680
|
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2524
2681
|
this.binding = binding;
|
|
2525
2682
|
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2526
|
-
this.blobs = new LocalBlobStore(
|
|
2527
|
-
this.
|
|
2528
|
-
this.
|
|
2529
|
-
this.
|
|
2683
|
+
this.blobs = new LocalBlobStore(join13(stateDirectory, "blobs"));
|
|
2684
|
+
this.projectScope = options.scope === "project";
|
|
2685
|
+
this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
|
|
2686
|
+
this.publication = new FilePublicationState(state.db, options.scope);
|
|
2687
|
+
this.queue = new PublicationQueue(this.publication, this.blobs, {
|
|
2688
|
+
missingBlobs: (hashes) => this.client.missingBlobs(hashes),
|
|
2689
|
+
uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
|
|
2690
|
+
commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, this.sharedConfig.revision) : this.client.commitFiles(batch)
|
|
2691
|
+
});
|
|
2692
|
+
this.index = new SyncState(state, root, options.scope);
|
|
2693
|
+
this.ownRoot = this.projectScope ? root : join13(root, binding.memberId);
|
|
2694
|
+
this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
|
|
2530
2695
|
const started = performance.now();
|
|
2531
2696
|
const epoch = Date.now();
|
|
2532
2697
|
this.clock = options.clock ?? (() => epoch + performance.now() - started);
|
|
@@ -2537,6 +2702,13 @@ var FileSynchronizer = class {
|
|
|
2537
2702
|
root;
|
|
2538
2703
|
options;
|
|
2539
2704
|
index;
|
|
2705
|
+
publication;
|
|
2706
|
+
common;
|
|
2707
|
+
projectScope;
|
|
2708
|
+
owner;
|
|
2709
|
+
sharedConfig;
|
|
2710
|
+
rulesFingerprint;
|
|
2711
|
+
excludes = ignored;
|
|
2540
2712
|
binding;
|
|
2541
2713
|
client;
|
|
2542
2714
|
blobs;
|
|
@@ -2561,9 +2733,11 @@ var FileSynchronizer = class {
|
|
|
2561
2733
|
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
2562
2734
|
throw new Error("Invalid stable wait interval.");
|
|
2563
2735
|
this.settleMs = value;
|
|
2736
|
+
this.common?.setSettleMs(value);
|
|
2564
2737
|
}
|
|
2565
2738
|
close() {
|
|
2566
2739
|
this.stopped = true;
|
|
2740
|
+
this.common?.close();
|
|
2567
2741
|
this.abort.abort();
|
|
2568
2742
|
this.watcher?.close();
|
|
2569
2743
|
this.watcher = void 0;
|
|
@@ -2581,8 +2755,56 @@ var FileSynchronizer = class {
|
|
|
2581
2755
|
};
|
|
2582
2756
|
try {
|
|
2583
2757
|
try {
|
|
2758
|
+
if (this.projectScope) {
|
|
2759
|
+
try {
|
|
2760
|
+
this.sharedConfig = await this.client.sharedFilesConfig();
|
|
2761
|
+
} catch (error) {
|
|
2762
|
+
if (error instanceof HubRequestError && error.status === 404) return report;
|
|
2763
|
+
throw error;
|
|
2764
|
+
}
|
|
2765
|
+
const fingerprint = JSON.stringify(this.sharedConfig);
|
|
2766
|
+
const cached = this.state.db.prepare("SELECT config_json FROM project_file_settings WHERE root=?").get(this.root);
|
|
2767
|
+
const previous = cached ? JSON.parse(String(cached.config_json)) : void 0;
|
|
2768
|
+
const wasMaintainer = previous?.maintainer === this.binding.memberId;
|
|
2769
|
+
const isMaintainer = this.sharedConfig.maintainer === this.binding.memberId;
|
|
2770
|
+
const publication = this.publication.publication(this.root);
|
|
2771
|
+
if (wasMaintainer && publication.initialized && publication.revision > (this.index.revision(PROJECT_FILE_OWNER) ?? -1))
|
|
2772
|
+
this.index.acknowledgeOwnFiles(
|
|
2773
|
+
PROJECT_FILE_OWNER,
|
|
2774
|
+
publication.revision,
|
|
2775
|
+
[...this.publication.ownerFiles(this.root)].map(([path, file]) => ({
|
|
2776
|
+
path,
|
|
2777
|
+
...file,
|
|
2778
|
+
revision: publication.revision
|
|
2779
|
+
}))
|
|
2780
|
+
);
|
|
2781
|
+
if (!isMaintainer) this.publication.discard(this.root);
|
|
2782
|
+
if (!wasMaintainer || !isMaintainer || previous?.revision !== this.sharedConfig.revision) {
|
|
2783
|
+
report.appliedFiles += await this.receive(PROJECT_FILE_OWNER);
|
|
2784
|
+
if (this.index.job(PROJECT_FILE_OWNER)) return report;
|
|
2785
|
+
}
|
|
2786
|
+
if (cached?.config_json !== fingerprint) {
|
|
2787
|
+
if (isMaintainer) await this.resetPublication();
|
|
2788
|
+
this.state.db.prepare(
|
|
2789
|
+
"INSERT INTO project_file_settings VALUES (?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json"
|
|
2790
|
+
).run(this.root, fingerprint);
|
|
2791
|
+
this.lastScan = -Infinity;
|
|
2792
|
+
}
|
|
2793
|
+
if (!isMaintainer) return report;
|
|
2794
|
+
this.excludes = (path) => ignored(path) || !sharedPathAllowed(path, this.sharedConfig.paths);
|
|
2795
|
+
} else {
|
|
2796
|
+
const rules = await loadLocalRules(this.root);
|
|
2797
|
+
this.excludes = rules.excludes;
|
|
2798
|
+
if (this.rulesFingerprint !== rules.fingerprint) {
|
|
2799
|
+
this.rulesFingerprint = rules.fingerprint;
|
|
2800
|
+
this.lastScan = -Infinity;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
const pending = this.publication.pending(this.root);
|
|
2804
|
+
if (pending?.batch.changes.some((change) => this.excludes(change.path)))
|
|
2805
|
+
await this.resetPublication();
|
|
2584
2806
|
await this.initializeAuthor();
|
|
2585
|
-
const ownStat = await lstat4(
|
|
2807
|
+
const ownStat = await lstat4(this.ownRoot);
|
|
2586
2808
|
if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
|
|
2587
2809
|
throw new Error("Author directory is unavailable or is a symlink.");
|
|
2588
2810
|
this.listen();
|
|
@@ -2593,14 +2815,14 @@ var FileSynchronizer = class {
|
|
|
2593
2815
|
const blocked = await this.refreshDirty(report.issues);
|
|
2594
2816
|
await this.audit(report.issues);
|
|
2595
2817
|
report.published = await this.queue.flush(this.root, this.clock());
|
|
2596
|
-
if (!this.
|
|
2818
|
+
if (!this.publication.pending(this.root) && !this.stopped) {
|
|
2597
2819
|
await this.stage(blocked, report.issues, settleMs);
|
|
2598
2820
|
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
2599
2821
|
}
|
|
2600
2822
|
} catch (error) {
|
|
2601
2823
|
report.issues.push(`publish: ${message(error)}`);
|
|
2602
2824
|
}
|
|
2603
|
-
if (!this.stopped) {
|
|
2825
|
+
if (!this.stopped && !this.projectScope) {
|
|
2604
2826
|
try {
|
|
2605
2827
|
for (const member of (await this.client.members()).members) {
|
|
2606
2828
|
if (member === this.binding.memberId || this.stopped) continue;
|
|
@@ -2614,8 +2836,16 @@ var FileSynchronizer = class {
|
|
|
2614
2836
|
report.issues.push(`receive: ${message(error)}`);
|
|
2615
2837
|
}
|
|
2616
2838
|
}
|
|
2617
|
-
|
|
2618
|
-
|
|
2839
|
+
if (this.common && !this.stopped) {
|
|
2840
|
+
const common = await this.common.cycle();
|
|
2841
|
+
report.published ||= common.published;
|
|
2842
|
+
report.appliedFiles += common.appliedFiles;
|
|
2843
|
+
report.waitingFiles += common.waitingFiles;
|
|
2844
|
+
report.pendingPublication ||= common.pendingPublication;
|
|
2845
|
+
report.issues.push(...common.issues.map((issue) => `project files: ${issue}`));
|
|
2846
|
+
}
|
|
2847
|
+
report.waitingFiles += this.index.index(true).filter((row) => !this.excludes(row.path)).length;
|
|
2848
|
+
report.pendingPublication ||= Boolean(this.publication.pending(this.root));
|
|
2619
2849
|
this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2620
2850
|
return report;
|
|
2621
2851
|
} finally {
|
|
@@ -2638,9 +2868,17 @@ var FileSynchronizer = class {
|
|
|
2638
2868
|
after = page.nextCursor;
|
|
2639
2869
|
}
|
|
2640
2870
|
}
|
|
2871
|
+
async resetPublication() {
|
|
2872
|
+
const manifest = await this.manifest(this.owner);
|
|
2873
|
+
for (const row of this.index.index()) this.index.event(row.path, this.clock());
|
|
2874
|
+
this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
|
|
2875
|
+
discardPending: true
|
|
2876
|
+
});
|
|
2877
|
+
this.lastScan = -Infinity;
|
|
2878
|
+
}
|
|
2641
2879
|
async initializeAuthor() {
|
|
2642
|
-
if (this.
|
|
2643
|
-
const manifest = await this.manifest(this.
|
|
2880
|
+
if (this.publication.publication(this.root).initialized) return;
|
|
2881
|
+
const manifest = await this.manifest(this.owner);
|
|
2644
2882
|
if (manifest.revision > 0 && this.options.adoptBaseline) {
|
|
2645
2883
|
for (const file of manifest.files) {
|
|
2646
2884
|
const local = await readStable(this.ownRoot, file.path);
|
|
@@ -2650,11 +2888,11 @@ var FileSynchronizer = class {
|
|
|
2650
2888
|
);
|
|
2651
2889
|
}
|
|
2652
2890
|
}
|
|
2653
|
-
const ownerPath = await safePath(this.root, this.
|
|
2891
|
+
const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
|
|
2654
2892
|
await mkdir9(ownerPath).catch((error) => {
|
|
2655
2893
|
if (error.code !== "EEXIST") throw error;
|
|
2656
2894
|
});
|
|
2657
|
-
this.
|
|
2895
|
+
this.publication.initializePublication(this.root, manifest.revision, manifest.files);
|
|
2658
2896
|
}
|
|
2659
2897
|
listen() {
|
|
2660
2898
|
if (this.options.watch === false || this.watcher || this.stopped) return;
|
|
@@ -2665,7 +2903,7 @@ var FileSynchronizer = class {
|
|
|
2665
2903
|
return;
|
|
2666
2904
|
}
|
|
2667
2905
|
const path = filename.toString().split(sep4).join("/");
|
|
2668
|
-
if (!
|
|
2906
|
+
if (!this.excludes(path)) this.index.event(path, this.clock());
|
|
2669
2907
|
});
|
|
2670
2908
|
this.watcher.on("error", (error) => {
|
|
2671
2909
|
this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
|
|
@@ -2678,12 +2916,12 @@ var FileSynchronizer = class {
|
|
|
2678
2916
|
}
|
|
2679
2917
|
}
|
|
2680
2918
|
async reconcile(prefix, issues) {
|
|
2681
|
-
const scan = await walk(this.ownRoot, prefix);
|
|
2919
|
+
const scan = await walk(this.ownRoot, prefix, this.excludes);
|
|
2682
2920
|
const now = this.clock();
|
|
2683
2921
|
for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
|
|
2684
2922
|
if (scan.complete) {
|
|
2685
2923
|
for (const prior of this.index.index()) {
|
|
2686
|
-
if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !
|
|
2924
|
+
if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !this.excludes(prior.path) && !scan.files.has(prior.path))
|
|
2687
2925
|
this.index.observe(prior.path, null, now);
|
|
2688
2926
|
}
|
|
2689
2927
|
}
|
|
@@ -2692,6 +2930,7 @@ var FileSynchronizer = class {
|
|
|
2692
2930
|
async refreshDirty(issues) {
|
|
2693
2931
|
const blocked = /* @__PURE__ */ new Set();
|
|
2694
2932
|
for (const prior of this.index.index(true)) {
|
|
2933
|
+
if (this.excludes(prior.path)) continue;
|
|
2695
2934
|
if (this.stopped) break;
|
|
2696
2935
|
try {
|
|
2697
2936
|
const current = await metadata(this.ownRoot, prior.path);
|
|
@@ -2712,6 +2951,7 @@ var FileSynchronizer = class {
|
|
|
2712
2951
|
this.clock() - (this.options.auditAgeMs ?? 864e5),
|
|
2713
2952
|
this.options.auditBudget ?? 20
|
|
2714
2953
|
)) {
|
|
2954
|
+
if (this.excludes(prior.path)) continue;
|
|
2715
2955
|
if (this.stopped || bytes > 20 * 1024 * 1024) break;
|
|
2716
2956
|
try {
|
|
2717
2957
|
const local = await readStable(this.ownRoot, prior.path);
|
|
@@ -2727,10 +2967,10 @@ var FileSynchronizer = class {
|
|
|
2727
2967
|
}
|
|
2728
2968
|
async stage(blocked, issues, settleMs) {
|
|
2729
2969
|
const ready = this.index.index(true).filter(
|
|
2730
|
-
(row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
|
|
2970
|
+
(row) => !this.excludes(row.path) && !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
|
|
2731
2971
|
);
|
|
2732
2972
|
if (!ready.length) return;
|
|
2733
|
-
const baseline = this.
|
|
2973
|
+
const baseline = this.publication.ownerFiles(this.root);
|
|
2734
2974
|
const planned = new Map(baseline);
|
|
2735
2975
|
const changes = [];
|
|
2736
2976
|
const snapshots = [];
|
|
@@ -2788,9 +3028,9 @@ var FileSynchronizer = class {
|
|
|
2788
3028
|
const rootStat = await lstat4(this.ownRoot);
|
|
2789
3029
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2790
3030
|
throw new Error("Author directory disappeared before publication.");
|
|
2791
|
-
this.
|
|
3031
|
+
this.publication.enqueue(this.root, {
|
|
2792
3032
|
operationId: randomUUID6(),
|
|
2793
|
-
parentRevision: this.
|
|
3033
|
+
parentRevision: this.publication.publication(this.root).revision,
|
|
2794
3034
|
changes
|
|
2795
3035
|
});
|
|
2796
3036
|
for (const snapshot of snapshots)
|
|
@@ -2834,7 +3074,7 @@ var FileSynchronizer = class {
|
|
|
2834
3074
|
}
|
|
2835
3075
|
}
|
|
2836
3076
|
const job = this.index.job(owner);
|
|
2837
|
-
const ownerRoot = await safePath(this.root, owner);
|
|
3077
|
+
const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
|
|
2838
3078
|
await mkdir9(ownerRoot).catch((error) => {
|
|
2839
3079
|
if (error.code !== "EEXIST") throw error;
|
|
2840
3080
|
});
|
|
@@ -2891,7 +3131,7 @@ function message(error) {
|
|
|
2891
3131
|
|
|
2892
3132
|
// apps/client/sessions/synchronizer.ts
|
|
2893
3133
|
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2894
|
-
import { join as
|
|
3134
|
+
import { join as join15 } from "node:path";
|
|
2895
3135
|
|
|
2896
3136
|
// packages/protocol/session-key.ts
|
|
2897
3137
|
import { createHash as createHash9 } from "node:crypto";
|
|
@@ -2900,7 +3140,7 @@ function sessionKey(agent, nativeId) {
|
|
|
2900
3140
|
}
|
|
2901
3141
|
|
|
2902
3142
|
// apps/client/sessions/source.ts
|
|
2903
|
-
import { open as
|
|
3143
|
+
import { open as open5, stat, realpath as realpath5 } from "node:fs/promises";
|
|
2904
3144
|
import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
|
|
2905
3145
|
import * as zlib from "node:zlib";
|
|
2906
3146
|
|
|
@@ -3058,7 +3298,7 @@ async function readSource(agent, path, projectDir) {
|
|
|
3058
3298
|
let result2;
|
|
3059
3299
|
if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
|
|
3060
3300
|
else {
|
|
3061
|
-
const handle = await
|
|
3301
|
+
const handle = await open5(path, "r");
|
|
3062
3302
|
let bytes;
|
|
3063
3303
|
try {
|
|
3064
3304
|
const size = (await handle.stat()).size;
|
|
@@ -3097,14 +3337,14 @@ async function readSource(agent, path, projectDir) {
|
|
|
3097
3337
|
|
|
3098
3338
|
// apps/client/sessions/discovery.ts
|
|
3099
3339
|
import { opendir as opendir2 } from "node:fs/promises";
|
|
3100
|
-
import { join as
|
|
3340
|
+
import { join as join14 } from "node:path";
|
|
3101
3341
|
async function* candidates(directory, agent) {
|
|
3102
3342
|
const queue = [directory];
|
|
3103
3343
|
while (queue.length) {
|
|
3104
3344
|
const current = queue.shift();
|
|
3105
3345
|
const dir = await opendir2(current);
|
|
3106
3346
|
for await (const entry of dir) {
|
|
3107
|
-
const path =
|
|
3347
|
+
const path = join14(current, entry.name);
|
|
3108
3348
|
if (entry.isDirectory()) queue.push(path);
|
|
3109
3349
|
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
3110
3350
|
yield path;
|
|
@@ -3192,7 +3432,7 @@ var SessionSynchronizer = class {
|
|
|
3192
3432
|
const binding = state.binding(root);
|
|
3193
3433
|
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
3194
3434
|
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
3195
|
-
this.blobs = new LocalBlobStore(
|
|
3435
|
+
this.blobs = new LocalBlobStore(join15(directory, "blobs"));
|
|
3196
3436
|
this.index = new SessionState(state, root);
|
|
3197
3437
|
this.discovery = new SessionDiscovery(this.index);
|
|
3198
3438
|
}
|
|
@@ -3348,6 +3588,7 @@ var SyncJob = class {
|
|
|
3348
3588
|
this.sessions = new SessionSynchronizer(state, root, directory, this.abort.signal);
|
|
3349
3589
|
if (options.retry) {
|
|
3350
3590
|
state.retryPublication(root);
|
|
3591
|
+
new FilePublicationState(state.db, "project").retryPublication(root);
|
|
3351
3592
|
this.sessions.index.retry();
|
|
3352
3593
|
}
|
|
3353
3594
|
const overrides = {};
|
|
@@ -3449,12 +3690,15 @@ var SyncJob = class {
|
|
|
3449
3690
|
(e) => Boolean(e)
|
|
3450
3691
|
) : []
|
|
3451
3692
|
].map(redact);
|
|
3452
|
-
const pendingDownloads =
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3693
|
+
const pendingDownloads = ["receive_jobs", "project_receive_jobs"].reduce(
|
|
3694
|
+
(total, table) => total + Number(
|
|
3695
|
+
this.state.db.prepare(
|
|
3696
|
+
`SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM ${table} WHERE root=?`
|
|
3697
|
+
).get(this.root).n
|
|
3698
|
+
),
|
|
3699
|
+
0
|
|
3456
3700
|
);
|
|
3457
|
-
const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0);
|
|
3701
|
+
const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0) + (new FilePublicationState(this.state.db, "project").pending(this.root)?.batch.changes.length ?? 0);
|
|
3458
3702
|
const now = Date.now(), checkedAt = new Date(now).toISOString();
|
|
3459
3703
|
this.control.updateSync({
|
|
3460
3704
|
checkedAt,
|