@0xmaxma/claude-gateway 1.7.9 → 1.7.10
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 +11 -3
- package/dist/agent/dreaming/accept.d.ts.map +1 -1
- package/dist/agent/dreaming/accept.js +4 -1
- package/dist/agent/dreaming/accept.js.map +1 -1
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +2 -0
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
- package/dist/agent/dreaming/reviewer.js +9 -2
- package/dist/agent/dreaming/reviewer.js.map +1 -1
- package/dist/agent/dreaming/staleness.d.ts +7 -1
- package/dist/agent/dreaming/staleness.d.ts.map +1 -1
- package/dist/agent/dreaming/staleness.js +17 -2
- package/dist/agent/dreaming/staleness.js.map +1 -1
- package/dist/agent/dreaming/types.d.ts +17 -1
- package/dist/agent/dreaming/types.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.d.ts +43 -0
- package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.js +130 -0
- package/dist/agent/knowledge/archive-db.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts +3 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +30 -1
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/knowledge/index.d.ts +11 -4
- package/dist/agent/knowledge/index.d.ts.map +1 -1
- package/dist/agent/knowledge/index.js +22 -1
- package/dist/agent/knowledge/index.js.map +1 -1
- package/dist/agent/knowledge/indexer.d.ts +0 -7
- package/dist/agent/knowledge/indexer.d.ts.map +1 -1
- package/dist/agent/knowledge/indexer.js +34 -7
- package/dist/agent/knowledge/indexer.js.map +1 -1
- package/dist/agent/knowledge/lifecycle.d.ts +20 -4
- package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
- package/dist/agent/knowledge/lifecycle.js +33 -4
- package/dist/agent/knowledge/lifecycle.js.map +1 -1
- package/dist/agent/knowledge/reflection.d.ts +114 -0
- package/dist/agent/knowledge/reflection.d.ts.map +1 -0
- package/dist/agent/knowledge/reflection.js +447 -0
- package/dist/agent/knowledge/reflection.js.map +1 -0
- package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
- package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-dedup.js +187 -0
- package/dist/agent/knowledge/shared-dedup.js.map +1 -0
- package/dist/agent/knowledge/shared-promote.d.ts +82 -7
- package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-promote.js +244 -11
- package/dist/agent/knowledge/shared-promote.js.map +1 -1
- package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
- package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-staleness.js +149 -0
- package/dist/agent/knowledge/shared-staleness.js.map +1 -0
- package/dist/agent/knowledge/shared-writer.d.ts +19 -0
- package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-writer.js +42 -0
- package/dist/agent/knowledge/shared-writer.js.map +1 -1
- package/dist/agent/knowledge/types.d.ts +24 -0
- package/dist/agent/knowledge/types.d.ts.map +1 -1
- package/dist/agent/runner.d.ts +7 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +92 -15
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/workspace-loader.d.ts +17 -2
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +17 -1
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +2 -1
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/apps/compose-generator.js +7 -4
- package/dist/apps/compose-generator.js.map +1 -1
- package/dist/apps/installer.d.ts +86 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +440 -52
- package/dist/apps/installer.js.map +1 -1
- package/dist/index.js +37 -1
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +20 -3
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +120 -5
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +146 -27
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/draft-phantom.d.ts +49 -0
- package/dist/shell/draft-phantom.d.ts.map +1 -0
- package/dist/shell/draft-phantom.js +59 -0
- package/dist/shell/draft-phantom.js.map +1 -0
- package/dist/shell/submit-diag.d.ts +67 -0
- package/dist/shell/submit-diag.d.ts.map +1 -0
- package/dist/shell/submit-diag.js +69 -0
- package/dist/shell/submit-diag.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/mcp/tools/discord/module.ts +23 -15
- package/mcp/tools/memory/archive-reader.test.ts +25 -1
- package/mcp/tools/memory/archive-reader.ts +70 -12
- package/mcp/tools/memory/archive-writer.test.ts +168 -0
- package/mcp/tools/memory/archive-writer.ts +137 -0
- package/mcp/tools/memory/module.test.ts +187 -0
- package/mcp/tools/memory/module.ts +192 -6
- package/mcp/tools/telegram/module.ts +10 -2
- package/mcp/tools/telegram/receiver-server.ts +9 -2
- package/mcp/tools/telegram/typing.ts +178 -34
- package/package.json +1 -1
package/dist/apps/installer.js
CHANGED
|
@@ -96,6 +96,42 @@ function isValidTimezone(tz) {
|
|
|
96
96
|
// Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
|
|
97
97
|
const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
|
|
98
98
|
// ─── Installer ────────────────────────────────────────────────────────────────
|
|
99
|
+
const UUID_RE_SRC = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
100
|
+
/**
|
|
101
|
+
* Scratch directories `runUpdate` creates beside an app's install path:
|
|
102
|
+
* `.cg-update-<app>-<uuid>` (staging checkout) and `<appDir>-old-<uuid>` /
|
|
103
|
+
* `<appDir>-failed-<uuid>` (release snapshots). All three carry a v4 UUID, so
|
|
104
|
+
* the pattern cannot match an app directory named by a user.
|
|
105
|
+
*/
|
|
106
|
+
const STALE_UPDATE_DIR_RE = new RegExp(`^\\.cg-update-.+-${UUID_RE_SRC}$|-(?:old|failed)-${UUID_RE_SRC}$`);
|
|
107
|
+
/**
|
|
108
|
+
* Rows from a `docker compose … --format json` call. Compose has emitted both a
|
|
109
|
+
* single JSON array and one object per line across its 2.x line, so accept
|
|
110
|
+
* either rather than silently reading a newer/older daemon as "nothing here".
|
|
111
|
+
*/
|
|
112
|
+
function parseJsonRows(stdout) {
|
|
113
|
+
const text = stdout.trim();
|
|
114
|
+
if (!text)
|
|
115
|
+
return [];
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(text);
|
|
118
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
119
|
+
}
|
|
120
|
+
catch { /* not a single document — try line-delimited below */ }
|
|
121
|
+
const rows = [];
|
|
122
|
+
for (const line of text.split('\n')) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (!trimmed)
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
rows.push(JSON.parse(trimmed));
|
|
128
|
+
}
|
|
129
|
+
catch { /* skip a line that is not JSON */ }
|
|
130
|
+
}
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
/** Private tag the update holds on an image so a rollback can put it back. */
|
|
134
|
+
const ROLLBACK_TAG_PREFIX = 'cg-rollback-';
|
|
99
135
|
class AppInstaller {
|
|
100
136
|
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
|
|
101
137
|
this.registry = registry;
|
|
@@ -720,23 +756,29 @@ class AppInstaller {
|
|
|
720
756
|
* read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
|
|
721
757
|
* returns `[]` on any failure, mirroring {@link discoverVolumes}.
|
|
722
758
|
*/
|
|
723
|
-
|
|
759
|
+
/**
|
|
760
|
+
* The paths a generated compose file may have resolved this app dir to.
|
|
761
|
+
* Local-dev installs symlink the app dir into appsDir, and the generated
|
|
762
|
+
* compose resolves bind sources against the symlink's *realpath*, so both the
|
|
763
|
+
* symlink path and its target must be treated as the app root.
|
|
764
|
+
*/
|
|
765
|
+
bindMountBases(appDir) {
|
|
766
|
+
const bases = [appDir];
|
|
767
|
+
try {
|
|
768
|
+
const real = fs.realpathSync(appDir);
|
|
769
|
+
if (real !== appDir)
|
|
770
|
+
bases.push(real);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
/* app dir unreadable — fall back to the literal path */
|
|
774
|
+
}
|
|
775
|
+
return bases;
|
|
776
|
+
}
|
|
777
|
+
discoverBindMounts(appName, appDir, onError = 'empty') {
|
|
778
|
+
const bases = this.bindMountBases(appDir);
|
|
724
779
|
try {
|
|
725
780
|
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
|
|
726
781
|
const parsed = JSON.parse(stdout);
|
|
727
|
-
// Local-dev installs symlink the app dir into appsDir, and the generated
|
|
728
|
-
// compose resolves bind sources against the symlink's *realpath*. Match a
|
|
729
|
-
// source that sits under either the symlink path or its target, so those
|
|
730
|
-
// bind mounts are not wrongly excluded.
|
|
731
|
-
const bases = [appDir];
|
|
732
|
-
try {
|
|
733
|
-
const real = fs.realpathSync(appDir);
|
|
734
|
-
if (real !== appDir)
|
|
735
|
-
bases.push(real);
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
/* app dir unreadable — fall back to the literal path */
|
|
739
|
-
}
|
|
740
782
|
const rels = new Set();
|
|
741
783
|
for (const svc of Object.values(parsed.services ?? {})) {
|
|
742
784
|
for (const vol of svc.volumes ?? []) {
|
|
@@ -753,8 +795,18 @@ class AppInstaller {
|
|
|
753
795
|
}
|
|
754
796
|
return Array.from(rels).sort();
|
|
755
797
|
}
|
|
756
|
-
catch {
|
|
757
|
-
|
|
798
|
+
catch (err) {
|
|
799
|
+
if (onError === 'empty')
|
|
800
|
+
return [];
|
|
801
|
+
// Fail closed: the caller is about to move this app's directory, so an
|
|
802
|
+
// unknown bind set would silently strand live state. Only an app whose
|
|
803
|
+
// stored compose anchors nothing to the app dir (under either base) is
|
|
804
|
+
// safe to treat as bind-free.
|
|
805
|
+
const composePath = path.join(appDir, 'docker-compose.yml');
|
|
806
|
+
const compose = fs.existsSync(composePath) ? fs.readFileSync(composePath, 'utf-8') : '';
|
|
807
|
+
if (!bases.some((b) => compose.includes(b)))
|
|
808
|
+
return [];
|
|
809
|
+
throw new Error(`Cannot safely discover bind mounts for update: ${err.message}`);
|
|
758
810
|
}
|
|
759
811
|
}
|
|
760
812
|
/**
|
|
@@ -823,6 +875,52 @@ class AppInstaller {
|
|
|
823
875
|
* Returns a cancel function. No-op (returns a noop canceller) when both caps
|
|
824
876
|
* are disabled. The timer is `unref`'d so it never holds the event loop open.
|
|
825
877
|
*/
|
|
878
|
+
/**
|
|
879
|
+
* Reclaim update scratch directories a crashed or killed update left behind:
|
|
880
|
+
* the `.cg-update-*` staging checkout and the `-old-`/`-failed-` release
|
|
881
|
+
* snapshots. Staging moved next to the install path so the swap is a
|
|
882
|
+
* same-filesystem rename, which also means `/tmp` cleanup no longer collects
|
|
883
|
+
* it — without this sweep a mid-update crash leaks a full app checkout
|
|
884
|
+
* forever.
|
|
885
|
+
*
|
|
886
|
+
* **Boot only.** An update in flight owns directories matching these names,
|
|
887
|
+
* so this must run before any update can start. Never throws; a directory it
|
|
888
|
+
* cannot remove is reported and skipped.
|
|
889
|
+
*/
|
|
890
|
+
async sweepStaleUpdateDirs() {
|
|
891
|
+
const swept = [];
|
|
892
|
+
let names;
|
|
893
|
+
try {
|
|
894
|
+
names = fs.readdirSync(this.appsDir);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return swept; // no apps dir yet
|
|
898
|
+
}
|
|
899
|
+
// An installPath is authoritative — never remove a directory an app still
|
|
900
|
+
// points at, however its name happens to look.
|
|
901
|
+
let live;
|
|
902
|
+
try {
|
|
903
|
+
live = new Set((await this.registry.list()).map((a) => a.installPath));
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return swept; // registry unreadable — do not guess
|
|
907
|
+
}
|
|
908
|
+
for (const name of names) {
|
|
909
|
+
if (!STALE_UPDATE_DIR_RE.test(name))
|
|
910
|
+
continue;
|
|
911
|
+
const full = path.join(this.appsDir, name);
|
|
912
|
+
if (live.has(full))
|
|
913
|
+
continue;
|
|
914
|
+
try {
|
|
915
|
+
this.rmrf(full);
|
|
916
|
+
swept.push(full);
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
console.warn(`[installer] failed to sweep stale update dir "${full}": ${err.message}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return swept;
|
|
923
|
+
}
|
|
826
924
|
startBackupCleanup() {
|
|
827
925
|
const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
|
|
828
926
|
if (retention <= 0 && maxAgeDays <= 0)
|
|
@@ -1357,7 +1455,12 @@ class AppInstaller {
|
|
|
1357
1455
|
this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1358
1456
|
}
|
|
1359
1457
|
}
|
|
1360
|
-
|
|
1458
|
+
// Stage beside the durable install path so the directory swap and bind-data
|
|
1459
|
+
// moves are same-filesystem renames, not cross-device copies/failures.
|
|
1460
|
+
const tmpDir = path.join(path.dirname(entry.installPath), `.cg-update-${appName}-${crypto.randomUUID()}`);
|
|
1461
|
+
// Rollback tags held on the images this update is about to build over.
|
|
1462
|
+
// Declared out here so every exit path can drop them again.
|
|
1463
|
+
let preservedImages = [];
|
|
1361
1464
|
try {
|
|
1362
1465
|
// ── Shallow fetch of specific commit into tmp dir ─────────────────────
|
|
1363
1466
|
this.log(job, `Cloning ${target.repo}`);
|
|
@@ -1392,12 +1495,20 @@ class AppInstaller {
|
|
|
1392
1495
|
version: newVersion,
|
|
1393
1496
|
commit: target.newCommit,
|
|
1394
1497
|
installPath: tmpDir,
|
|
1395
|
-
|
|
1498
|
+
agentDeclaration: generated.agentDeclaration,
|
|
1396
1499
|
...(agentPaths ? { agentPaths } : {}),
|
|
1397
1500
|
};
|
|
1398
1501
|
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
1399
1502
|
this.agentManager.injectAgentService(newEntry);
|
|
1400
1503
|
}
|
|
1504
|
+
// Pin the images the app is running before the build takes their tags
|
|
1505
|
+
// over. A `build:` service's new image reuses the old tag
|
|
1506
|
+
// (`<project>-<service>`), so once the build succeeds that tag names the
|
|
1507
|
+
// new release: rolling only the source back would bring the app up on the
|
|
1508
|
+
// failed release's image and crash-loop on a "successful" rollback. This
|
|
1509
|
+
// has to happen before the build — afterwards the old image has no
|
|
1510
|
+
// reference left to grab it by.
|
|
1511
|
+
preservedImages = this.preserveRunningImages(appName, entry.installPath, job);
|
|
1401
1512
|
// ── Build new images in tmp dir ───────────────────────────────────────
|
|
1402
1513
|
this.log(job, 'Building new images');
|
|
1403
1514
|
this.run(['docker', 'compose', '-p', appName, 'build'], tmpDir, 600000);
|
|
@@ -1409,30 +1520,72 @@ class AppInstaller {
|
|
|
1409
1520
|
this.log(job, 'MEMORY.md backed up');
|
|
1410
1521
|
}
|
|
1411
1522
|
}
|
|
1523
|
+
// Discover the app-owned bind paths while the old compose file still points
|
|
1524
|
+
// at the durable install directory. They must move with the release swap so
|
|
1525
|
+
// cleanup cannot delete live state. This query fails closed, so it runs
|
|
1526
|
+
// *before* routes and sockets come down — a throw here would otherwise
|
|
1527
|
+
// leave the app running but unreachable, with no path back.
|
|
1528
|
+
const oldBindMounts = this.discoverBindMounts(appName, entry.installPath, 'throw');
|
|
1412
1529
|
// ── Deregister old routes before taking down containers ───────────────
|
|
1413
1530
|
this.callbacks.deregisterRoutes(appName);
|
|
1414
1531
|
this.callbacks.stopSockets(appName);
|
|
1415
|
-
// Capture the current (old) image IDs while the old
|
|
1416
|
-
//
|
|
1417
|
-
// `compose down` that would collide with the new stack (issue #283).
|
|
1532
|
+
// Capture the current (old) image IDs while the old compose file is still
|
|
1533
|
+
// the live one, so the reclaim below targets exactly this app's images.
|
|
1418
1534
|
const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
|
|
1419
|
-
// Also capture the app's declared image refs (repo:tag) so a superseded
|
|
1420
|
-
// *pulled* tag can be reclaimed after the update (issue #302).
|
|
1421
1535
|
const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
|
|
1422
|
-
// ──
|
|
1536
|
+
// ── Swap dirs before starting the new containers ───────────────────────
|
|
1537
|
+
// The new compose file's bind sources are anchored to finalDir. Starting
|
|
1538
|
+
// before this swap would bind the old directory inode then delete it.
|
|
1423
1539
|
this.log(job, 'Stopping old containers');
|
|
1424
1540
|
this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
|
|
1425
|
-
|
|
1426
|
-
|
|
1541
|
+
this.log(job, 'Swapping app directories');
|
|
1542
|
+
const finalDir = entry.installPath;
|
|
1543
|
+
const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
|
|
1544
|
+
let swapped = false;
|
|
1545
|
+
let failedDir = null;
|
|
1546
|
+
const movedBindMounts = [];
|
|
1427
1547
|
try {
|
|
1428
|
-
|
|
1548
|
+
fs.renameSync(finalDir, oldBackupDir);
|
|
1549
|
+
fs.renameSync(tmpDir, finalDir);
|
|
1550
|
+
swapped = true;
|
|
1551
|
+
this.moveBindMounts(oldBackupDir, finalDir, oldBindMounts, job, movedBindMounts);
|
|
1552
|
+
// The source checkout is now permanent. Rewrite the compose file with
|
|
1553
|
+
// finalDir as its base so every bind source and build context resolves
|
|
1554
|
+
// to the durable path — this call is kept for that side effect; its
|
|
1555
|
+
// return value necessarily matches `generated` (same app.yaml). The
|
|
1556
|
+
// rewrite drops the injected agent service, so re-inject it after.
|
|
1557
|
+
const finalComposePath = path.join(finalDir, 'docker-compose.yml');
|
|
1558
|
+
const finalYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(path.join(finalDir, 'app.yaml'), 'utf-8'), finalDir);
|
|
1559
|
+
(0, compose_generator_1.generateCompose)(finalYaml, appName, finalDir, finalComposePath);
|
|
1560
|
+
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
1561
|
+
this.agentManager.injectAgentService({ ...newEntry, installPath: finalDir });
|
|
1562
|
+
}
|
|
1563
|
+
// ── Start new containers ────────────────────────────────────────────
|
|
1564
|
+
this.log(job, 'Starting new containers');
|
|
1565
|
+
this.composeUp(appName, finalDir, job);
|
|
1429
1566
|
}
|
|
1430
1567
|
catch (upErr) {
|
|
1431
|
-
// Rollback: bring old containers back up from old install path
|
|
1432
1568
|
this.log(job, 'New containers failed — rolling back to previous version');
|
|
1433
1569
|
let rollbackFailed = false;
|
|
1570
|
+
failedDir = `${finalDir}-failed-${crypto.randomUUID()}`;
|
|
1434
1571
|
try {
|
|
1435
|
-
|
|
1572
|
+
if (swapped) {
|
|
1573
|
+
fs.renameSync(finalDir, failedDir);
|
|
1574
|
+
fs.renameSync(oldBackupDir, finalDir);
|
|
1575
|
+
this.restoreBindMounts(failedDir, finalDir, movedBindMounts, job);
|
|
1576
|
+
}
|
|
1577
|
+
else if (fs.existsSync(oldBackupDir)) {
|
|
1578
|
+
fs.renameSync(oldBackupDir, finalDir);
|
|
1579
|
+
}
|
|
1580
|
+
// Point every tag back at the image the app was actually running
|
|
1581
|
+
// before this update built over it, so the restored source and the
|
|
1582
|
+
// restored image are the same release. Falls back to rebuilding from
|
|
1583
|
+
// the restored source when an old image is no longer on disk.
|
|
1584
|
+
const rebuild = !this.restorePreservedImages(preservedImages, finalDir, job);
|
|
1585
|
+
const upArgs = ['docker', 'compose', '-p', appName, 'up', '-d'];
|
|
1586
|
+
if (rebuild)
|
|
1587
|
+
upArgs.push('--build');
|
|
1588
|
+
this.run(upArgs, finalDir, rebuild ? 600000 : 120000);
|
|
1436
1589
|
this.callbacks.registerRoutes(appName, entry.ports.map((p) => ({
|
|
1437
1590
|
name: p.name,
|
|
1438
1591
|
service: p.service,
|
|
@@ -1442,38 +1595,27 @@ class AppInstaller {
|
|
|
1442
1595
|
rateLimit: p.rateLimit,
|
|
1443
1596
|
})));
|
|
1444
1597
|
await this.registry.updateStatus(appName, 'running');
|
|
1598
|
+
if (failedDir !== null)
|
|
1599
|
+
this.safeRmrf(failedDir, job, 'failed update dir');
|
|
1445
1600
|
}
|
|
1446
1601
|
catch (rollbackErr) {
|
|
1447
1602
|
rollbackFailed = true;
|
|
1448
1603
|
this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
|
|
1449
1604
|
}
|
|
1450
|
-
this.safeRmrf(tmpDir, job, 'update temp dir');
|
|
1451
1605
|
if (rollbackFailed) {
|
|
1452
1606
|
throw new Error(`Update failed and rollback also failed — app "${appName}" may be in a broken state. Check job logs for details.`);
|
|
1453
1607
|
}
|
|
1454
1608
|
throw upErr;
|
|
1455
1609
|
}
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
// the app name here throws ENOENT (issue #275). `entry.installPath` is the
|
|
1466
|
-
// authoritative location the `down`/rollback steps above already use.
|
|
1467
|
-
this.log(job, 'Swapping app directories');
|
|
1468
|
-
const finalDir = entry.installPath;
|
|
1469
|
-
const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
|
|
1470
|
-
fs.renameSync(finalDir, oldBackupDir);
|
|
1471
|
-
fs.renameSync(tmpDir, finalDir);
|
|
1472
|
-
// ── Restore MEMORY.md ─────────────────────────────────────────────────
|
|
1473
|
-
if (memoryBackup !== null && generated.agentDeclaration && this.agentManager) {
|
|
1474
|
-
this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
|
|
1475
|
-
this.log(job, 'MEMORY.md restored');
|
|
1476
|
-
}
|
|
1610
|
+
// The new stack is up: the previous images are no longer a rollback
|
|
1611
|
+
// target. Untag them before the reclaim below, which removes by image ID
|
|
1612
|
+
// and would be refused while a second reference exists.
|
|
1613
|
+
this.dropPreservedImageTags(preservedImages);
|
|
1614
|
+
preservedImages = [];
|
|
1615
|
+
// Capture the new stack's image IDs from its permanent compose file so
|
|
1616
|
+
// cleanup below never removes an image the new containers depend on.
|
|
1617
|
+
const newImageIds = this.captureComposeImageIds(appName, finalDir);
|
|
1618
|
+
const newImageRefs = this.captureComposeImageRefs(appName, finalDir);
|
|
1477
1619
|
// ── Update registry ───────────────────────────────────────────────────
|
|
1478
1620
|
const finalEntry = {
|
|
1479
1621
|
...newEntry,
|
|
@@ -1482,10 +1624,28 @@ class AppInstaller {
|
|
|
1482
1624
|
status: 'running',
|
|
1483
1625
|
};
|
|
1484
1626
|
await this.registry.upsert(finalEntry);
|
|
1485
|
-
// ── Re-create
|
|
1627
|
+
// ── Re-create, rename, or remove the app-agent registration ───────────
|
|
1628
|
+
// `upsertAgent` keys off the *new* agent name, so a release that drops or
|
|
1629
|
+
// renames its agent would otherwise strand the previous workspace symlink
|
|
1630
|
+
// and config.json entry. Both transitions are the same deregistration.
|
|
1631
|
+
const oldAgentName = entry.agentDeclaration?.name ?? null;
|
|
1632
|
+
const newAgentName = generated.agentDeclaration?.name ?? null;
|
|
1633
|
+
if (this.agentManager && oldAgentName !== null && oldAgentName !== newAgentName) {
|
|
1634
|
+
await this.agentManager.deleteAgentByName(oldAgentName);
|
|
1635
|
+
this.log(job, newAgentName === null
|
|
1636
|
+
? `Agent "${oldAgentName}" removed`
|
|
1637
|
+
: `Agent "${oldAgentName}" deregistered (renamed to "${newAgentName}")`);
|
|
1638
|
+
}
|
|
1486
1639
|
if (generated.agentDeclaration && this.agentManager) {
|
|
1487
1640
|
await this.agentManager.upsertAgent(finalEntry);
|
|
1488
1641
|
this.log(job, `Agent "${generated.agentDeclaration.name}" re-registered`);
|
|
1642
|
+
// MEMORY.md must be written *after* registration: restoreMemory resolves
|
|
1643
|
+
// the workspace through config.json, so on a rename the new name is not
|
|
1644
|
+
// resolvable until upsertAgent has written its entry.
|
|
1645
|
+
if (memoryBackup !== null) {
|
|
1646
|
+
this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
|
|
1647
|
+
this.log(job, 'MEMORY.md restored');
|
|
1648
|
+
}
|
|
1489
1649
|
await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
|
|
1490
1650
|
}
|
|
1491
1651
|
// ── Re-register proxy routes + sockets ───────────────────────────────
|
|
@@ -1534,6 +1694,7 @@ class AppInstaller {
|
|
|
1534
1694
|
this.log(job, `Update complete → ${newVersion}`);
|
|
1535
1695
|
}
|
|
1536
1696
|
catch (err) {
|
|
1697
|
+
this.dropPreservedImageTags(preservedImages);
|
|
1537
1698
|
if (fs.existsSync(tmpDir)) {
|
|
1538
1699
|
this.safeRmrf(tmpDir, job, 'update temp dir');
|
|
1539
1700
|
}
|
|
@@ -1975,6 +2136,135 @@ class AppInstaller {
|
|
|
1975
2136
|
}
|
|
1976
2137
|
}
|
|
1977
2138
|
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Create `targetDir` under `baseDir`, refusing to traverse or create through a
|
|
2141
|
+
* symlink. The check runs **before** each segment is created — a `mkdir -p`
|
|
2142
|
+
* that ran first would already have materialised directories on the far side
|
|
2143
|
+
* of a symlink, leaving the guard with nothing left to prevent.
|
|
2144
|
+
*
|
|
2145
|
+
* Scope: this guards the **destination** side — the freshly checked-out
|
|
2146
|
+
* release, which is the side an app repo controls. A bind path that was
|
|
2147
|
+
* already a symlink in the *previous* app dir is carried across as-is; that
|
|
2148
|
+
* preserves an escape the operator set up themselves rather than creating
|
|
2149
|
+
* one, and is the behaviour every release before this one had.
|
|
2150
|
+
*/
|
|
2151
|
+
ensureDirWithinNoSymlink(baseDir, targetDir) {
|
|
2152
|
+
const relative = path.relative(baseDir, targetDir);
|
|
2153
|
+
if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) {
|
|
2154
|
+
throw new Error(`Bind-mount path escapes the app directory: "${targetDir}"`);
|
|
2155
|
+
}
|
|
2156
|
+
let current = baseDir;
|
|
2157
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
2158
|
+
current = path.join(current, segment);
|
|
2159
|
+
let stat;
|
|
2160
|
+
try {
|
|
2161
|
+
stat = fs.lstatSync(current);
|
|
2162
|
+
}
|
|
2163
|
+
catch {
|
|
2164
|
+
stat = null;
|
|
2165
|
+
}
|
|
2166
|
+
if (stat === null) {
|
|
2167
|
+
fs.mkdirSync(current);
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
if (stat.isSymbolicLink()) {
|
|
2171
|
+
throw new Error(`Updated app bind-mount source must not be a symlink: "${current}"`);
|
|
2172
|
+
}
|
|
2173
|
+
if (!stat.isDirectory()) {
|
|
2174
|
+
throw new Error(`Updated app bind-mount path is not a directory: "${current}"`);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/** Reject a discovered bind path that does not stay inside both app roots. */
|
|
2179
|
+
isSafeBindRel(fromDir, toDir, rel) {
|
|
2180
|
+
if (rel.length === 0 || path.isAbsolute(rel))
|
|
2181
|
+
return false;
|
|
2182
|
+
const fromBase = fromDir.endsWith(path.sep) ? fromDir : fromDir + path.sep;
|
|
2183
|
+
const toBase = toDir.endsWith(path.sep) ? toDir : toDir + path.sep;
|
|
2184
|
+
return path.resolve(fromDir, rel).startsWith(fromBase)
|
|
2185
|
+
&& path.resolve(toDir, rel).startsWith(toBase);
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Move app-owned relative bind data across an update directory swap.
|
|
2189
|
+
* Renaming preserves the database's ownership and inode, unlike copying.
|
|
2190
|
+
*
|
|
2191
|
+
* A release legitimately ships content at a bind path (a `.gitkeep`, seed
|
|
2192
|
+
* files, a tracked `init.sql`), so a collision is normal, not an error. Live
|
|
2193
|
+
* state always wins — it is the data the issue exists to protect — but a
|
|
2194
|
+
* directory collision is **merged** entry by entry so release-provided files
|
|
2195
|
+
* the previous version never had still land. Every rename performed is
|
|
2196
|
+
* recorded, app-relative and in order, so a rollback can replay it backwards.
|
|
2197
|
+
*/
|
|
2198
|
+
moveBindMounts(fromDir, toDir, rels, job, moved) {
|
|
2199
|
+
for (const rel of rels) {
|
|
2200
|
+
if (!this.isSafeBindRel(fromDir, toDir, rel)) {
|
|
2201
|
+
this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
if (!fs.existsSync(path.resolve(fromDir, rel)))
|
|
2205
|
+
continue; // never created
|
|
2206
|
+
this.moveBindEntry(fromDir, toDir, rel, job, moved);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
moveBindEntry(fromDir, toDir, rel, job, moved) {
|
|
2210
|
+
const source = path.resolve(fromDir, rel);
|
|
2211
|
+
const destination = path.resolve(toDir, rel);
|
|
2212
|
+
this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
|
|
2213
|
+
let destStat;
|
|
2214
|
+
try {
|
|
2215
|
+
destStat = fs.lstatSync(destination);
|
|
2216
|
+
}
|
|
2217
|
+
catch {
|
|
2218
|
+
destStat = null;
|
|
2219
|
+
}
|
|
2220
|
+
if (destStat === null) {
|
|
2221
|
+
fs.renameSync(source, destination);
|
|
2222
|
+
moved?.push(rel);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
if (destStat.isDirectory() && fs.lstatSync(source).isDirectory()) {
|
|
2226
|
+
// Merge: recurse so a release-only file inside the directory survives.
|
|
2227
|
+
//
|
|
2228
|
+
// Entry-by-entry on purpose. Swapping the trees (move the live dir over
|
|
2229
|
+
// wholesale, then re-apply the release's own files on top) would be one
|
|
2230
|
+
// rename instead of one per live file, but `moved` would no longer be an
|
|
2231
|
+
// exact inverse: a rollback would carry those re-applied release files
|
|
2232
|
+
// into the restored previous app dir. Exact rollback beats the renames,
|
|
2233
|
+
// which are same-filesystem and only walk a directory the release also
|
|
2234
|
+
// ships content in.
|
|
2235
|
+
for (const name of fs.readdirSync(source)) {
|
|
2236
|
+
this.moveBindEntry(fromDir, toDir, `${rel}/${name}`, job, moved);
|
|
2237
|
+
}
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
this.log(job, `Warning: preserved existing bind-mount data at "${rel}" — the updated release's copy of that path was discarded`);
|
|
2241
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
2242
|
+
fs.renameSync(source, destination);
|
|
2243
|
+
moved?.push(rel);
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Undo {@link moveBindMounts} after a failed update: replay the recorded
|
|
2247
|
+
* renames backwards so the restored previous app dir gets its state back.
|
|
2248
|
+
*/
|
|
2249
|
+
restoreBindMounts(fromDir, toDir, moved, job) {
|
|
2250
|
+
for (const rel of [...moved].reverse()) {
|
|
2251
|
+
if (!this.isSafeBindRel(fromDir, toDir, rel)) {
|
|
2252
|
+
this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
const source = path.resolve(fromDir, rel);
|
|
2256
|
+
const destination = path.resolve(toDir, rel);
|
|
2257
|
+
if (!fs.existsSync(source) || fs.existsSync(destination))
|
|
2258
|
+
continue;
|
|
2259
|
+
try {
|
|
2260
|
+
this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
|
|
2261
|
+
fs.renameSync(source, destination);
|
|
2262
|
+
}
|
|
2263
|
+
catch (err) {
|
|
2264
|
+
this.log(job, `Warning: could not restore bind-mount path "${rel}": ${err.message}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
1978
2268
|
/**
|
|
1979
2269
|
* Stop conflicting containers then run `docker compose up -d --wait`.
|
|
1980
2270
|
* Captures container logs into the job on failure before rethrowing.
|
|
@@ -2076,6 +2366,104 @@ class AppInstaller {
|
|
|
2076
2366
|
* container back down (issue #283). Returns a de-duplicated list of image
|
|
2077
2367
|
* IDs, or `[]` on any error (nothing to reclaim / docker unavailable).
|
|
2078
2368
|
*/
|
|
2369
|
+
/**
|
|
2370
|
+
* Tag every image the app is *currently* running under a private
|
|
2371
|
+
* `<repo>:cg-rollback-<id>` name, and return the pairs.
|
|
2372
|
+
*
|
|
2373
|
+
* A `build:` service's new image reuses the tag of the one in production
|
|
2374
|
+
* (`<project>-<service>:latest`), so once the update's build succeeds that
|
|
2375
|
+
* tag names the new release. Rolling only the source directory back would
|
|
2376
|
+
* bring the app up on the failed release's image. Holding a second tag keeps
|
|
2377
|
+
* the old image addressable *and* alive — under the containerd image store an
|
|
2378
|
+
* untagged image is not kept around as a `<none>` image to find later.
|
|
2379
|
+
*
|
|
2380
|
+
* Only images this app builds are preserved: compose names them
|
|
2381
|
+
* `<project>-<service>`, and a pulled tag (`postgres:16-alpine`) is never
|
|
2382
|
+
* overwritten by a build, so it needs no protection. Best-effort throughout —
|
|
2383
|
+
* anything that cannot be preserved is simply absent from the result, and the
|
|
2384
|
+
* rollback rebuilds from the restored source instead.
|
|
2385
|
+
*/
|
|
2386
|
+
preserveRunningImages(appName, dir, job) {
|
|
2387
|
+
let rows;
|
|
2388
|
+
try {
|
|
2389
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--format', 'json'], dir, 15000);
|
|
2390
|
+
rows = parseJsonRows(stdout);
|
|
2391
|
+
}
|
|
2392
|
+
catch {
|
|
2393
|
+
return [];
|
|
2394
|
+
}
|
|
2395
|
+
const preserved = [];
|
|
2396
|
+
const seen = new Set();
|
|
2397
|
+
for (const row of rows) {
|
|
2398
|
+
if (typeof row !== 'object' || row === null)
|
|
2399
|
+
continue;
|
|
2400
|
+
const r = row;
|
|
2401
|
+
const id = typeof r.ID === 'string' ? r.ID.trim() : '';
|
|
2402
|
+
const repo = typeof r.Repository === 'string' ? r.Repository.trim() : '';
|
|
2403
|
+
const tag = typeof r.Tag === 'string' && r.Tag.trim() ? r.Tag.trim() : 'latest';
|
|
2404
|
+
if (!id || !repo.startsWith(`${appName}-`))
|
|
2405
|
+
continue;
|
|
2406
|
+
const ref = `${repo}:${tag}`;
|
|
2407
|
+
if (seen.has(ref))
|
|
2408
|
+
continue;
|
|
2409
|
+
seen.add(ref);
|
|
2410
|
+
const backupRef = `${repo}:${ROLLBACK_TAG_PREFIX}${id.replace(/^sha256:/, '').slice(0, 12)}`;
|
|
2411
|
+
try {
|
|
2412
|
+
this.run(['docker', 'image', 'tag', id, backupRef], dir, 15000);
|
|
2413
|
+
preserved.push({ ref, backupRef });
|
|
2414
|
+
}
|
|
2415
|
+
catch (err) {
|
|
2416
|
+
// Recorded with no backup reference: the rollback must know this tag is
|
|
2417
|
+
// unprotected and rebuild, rather than read an empty list as "no built
|
|
2418
|
+
// images, nothing at risk".
|
|
2419
|
+
preserved.push({ ref, backupRef: '' });
|
|
2420
|
+
this.log(job, `Warning: could not preserve image "${ref}" for rollback (${err instanceof Error ? err.message : String(err)})`);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return preserved;
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Put each preserved image back on the reference the update's build took
|
|
2427
|
+
* over. Returns true when nothing is at risk (an empty list — the app builds
|
|
2428
|
+
* no images) or every reference is back on its original image; false when at
|
|
2429
|
+
* least one could not be, so the caller rebuilds from the rolled-back source
|
|
2430
|
+
* rather than starting the failed release's build.
|
|
2431
|
+
*/
|
|
2432
|
+
restorePreservedImages(images, dir, job) {
|
|
2433
|
+
let allRestored = true;
|
|
2434
|
+
for (const { ref, backupRef } of images) {
|
|
2435
|
+
if (!backupRef) {
|
|
2436
|
+
allRestored = false;
|
|
2437
|
+
this.log(job, `Image "${ref}" was not preserved — rebuilding from the rolled-back source`);
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
try {
|
|
2441
|
+
this.run(['docker', 'image', 'tag', backupRef, ref], dir, 15000);
|
|
2442
|
+
this.log(job, `Restored image "${ref}" to the pre-update build`);
|
|
2443
|
+
}
|
|
2444
|
+
catch (err) {
|
|
2445
|
+
allRestored = false;
|
|
2446
|
+
this.log(job, `Warning: could not restore image "${ref}" (${err instanceof Error ? err.message : String(err)}) — rebuilding from the rolled-back source instead`);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return allRestored;
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Drop the private rollback tags. Removing a reference only untags the image
|
|
2453
|
+
* while another tag remains, so this never deletes an image the app still
|
|
2454
|
+
* uses. Must run before the post-update image reclaim: an extra tag would
|
|
2455
|
+
* make `docker image rm <id>` refuse.
|
|
2456
|
+
*/
|
|
2457
|
+
dropPreservedImageTags(images) {
|
|
2458
|
+
for (const { backupRef } of images) {
|
|
2459
|
+
if (!backupRef)
|
|
2460
|
+
continue;
|
|
2461
|
+
try {
|
|
2462
|
+
this.run(['docker', 'image', 'rm', backupRef], os.tmpdir(), 15000);
|
|
2463
|
+
}
|
|
2464
|
+
catch { /* already gone — non-fatal */ }
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2079
2467
|
captureComposeImageIds(appName, dir) {
|
|
2080
2468
|
try {
|
|
2081
2469
|
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--quiet'], dir, 15000);
|