quadwork 1.0.17 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/bin/quadwork.js +524 -89
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +16 -15
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +6 -5
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/064engxz5n7u9.js +1 -0
- package/out/_next/static/chunks/0738cfu-x.0ul.js +24 -0
- package/out/_next/static/chunks/{00cs~pv62864f.js → 0o97ax9om2kj1.js} +1 -1
- package/out/_next/static/chunks/0r-00ph4jahrl.css +2 -0
- package/out/_next/static/chunks/0spbjcw4anq15.js +1 -0
- package/out/_next/static/chunks/{0io_y3d0p5v~b.js → 15i5_ay.0ap.6.js} +2 -2
- package/out/_next/static/chunks/{turbopack-0sammtvunroor.js → turbopack-0wh29ykoy-rb5.js} +1 -1
- package/out/_not-found/__next._full.txt +17 -16
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +6 -5
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +17 -16
- package/out/app-shell/__next._full.txt +17 -16
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +6 -5
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +17 -16
- package/out/index.html +1 -1
- package/out/index.txt +16 -15
- package/out/project/_/__next._full.txt +18 -17
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +6 -5
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/memory/__next._full.txt +18 -17
- package/out/project/_/memory/__next._head.txt +4 -4
- package/out/project/_/memory/__next._index.txt +6 -5
- package/out/project/_/memory/__next._tree.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +3 -3
- package/out/project/_/memory/__next.project.$d$id.memory.txt +3 -3
- package/out/project/_/memory/__next.project.$d$id.txt +3 -3
- package/out/project/_/memory/__next.project.txt +3 -3
- package/out/project/_/memory.html +1 -1
- package/out/project/_/memory.txt +18 -17
- package/out/project/_/queue/__next._full.txt +18 -17
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +6 -5
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +18 -17
- package/out/project/_.html +1 -1
- package/out/project/_.txt +18 -17
- package/out/settings/__next._full.txt +18 -17
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +6 -5
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +18 -17
- package/out/setup/__next._full.txt +18 -17
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +6 -5
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +18 -17
- package/package.json +2 -1
- package/server/config.js +22 -1
- package/server/index.js +152 -6
- package/server/install-agentchattr.js +215 -0
- package/server/routes.js +162 -6
- package/templates/OVERNIGHT-QUEUE.md +35 -0
- package/templates/seeds/dev.AGENTS.md +9 -0
- package/templates/seeds/head.AGENTS.md +29 -2
- package/templates/seeds/reviewer1.AGENTS.md +9 -0
- package/templates/seeds/reviewer2.AGENTS.md +9 -0
- package/out/_next/static/chunks/08fgie1bcjynm.js +0 -1
- package/out/_next/static/chunks/0g7f4hvbz_1u~.js +0 -20
- package/out/_next/static/chunks/10b3c4k.q.yw..css +0 -2
- package/out/_next/static/chunks/14kr4rvjq-2md.js +0 -1
- /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_buildManifest.js +0 -0
- /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{TKQFu1hHpaRuo62RWWrUJ → 2NHQV76k9j9SbWmUaPhY3}/_ssgManifest.js +0 -0
package/bin/quadwork.js
CHANGED
|
@@ -84,50 +84,178 @@ function findAgentChattr(dir) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* Clone AgentChattr and set up its venv. Idempotent — safe to re-run
|
|
88
|
-
*
|
|
89
|
-
*
|
|
87
|
+
* Clone AgentChattr and set up its venv. Idempotent — safe to re-run on
|
|
88
|
+
* the same path, and safe to call repeatedly with different paths in
|
|
89
|
+
* the same process. Designed to support per-project clones (#181).
|
|
90
|
+
*
|
|
91
|
+
* Behavior on re-run:
|
|
92
|
+
* - Fully-installed path → no-op (skips clone, skips venv create, skips pip)
|
|
93
|
+
* - Missing run.py → clones (only after refusing to overwrite
|
|
94
|
+
* unrelated content; see safety rules below)
|
|
95
|
+
* - Missing venv → creates venv and reinstalls requirements
|
|
96
|
+
*
|
|
97
|
+
* Safety rules — never accidentally clean up unrelated directories:
|
|
98
|
+
* - Empty dir → safe to remove
|
|
99
|
+
* - Git repo whose origin contains "agentchattr" → safe to remove
|
|
100
|
+
* - Anything else → refuse, return null
|
|
101
|
+
*
|
|
102
|
+
* On failure, returns null and stores a human-readable reason on
|
|
103
|
+
* `installAgentChattr.lastError` so callers can surface it without
|
|
104
|
+
* changing the return shape.
|
|
90
105
|
*/
|
|
106
|
+
// Stale-lock thresholds for installAgentChattr().
|
|
107
|
+
// Lock files older than this OR whose owning pid is no longer alive are
|
|
108
|
+
// treated as crashed and reclaimed. Tuned to comfortably exceed the longest
|
|
109
|
+
// step (pip install of agentchattr requirements, ~120s timeout).
|
|
110
|
+
const INSTALL_LOCK_STALE_MS = 10 * 60 * 1000; // 10 min
|
|
111
|
+
const INSTALL_LOCK_WAIT_TOTAL_MS = 30 * 1000; // wait up to 30s for a peer
|
|
112
|
+
const INSTALL_LOCK_POLL_MS = 500;
|
|
113
|
+
|
|
114
|
+
function _isPidAlive(pid) {
|
|
115
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
116
|
+
try { process.kill(pid, 0); return true; }
|
|
117
|
+
catch (e) { return e.code === "EPERM"; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _readLock(lockFile) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = fs.readFileSync(lockFile, "utf-8").trim();
|
|
123
|
+
const [pidStr, tsStr] = raw.split(":");
|
|
124
|
+
return { pid: parseInt(pidStr, 10), ts: parseInt(tsStr, 10) || 0 };
|
|
125
|
+
} catch { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _isLockStale(lockFile) {
|
|
129
|
+
const info = _readLock(lockFile);
|
|
130
|
+
if (!info) return true; // unreadable → assume stale
|
|
131
|
+
if (Date.now() - info.ts > INSTALL_LOCK_STALE_MS) return true;
|
|
132
|
+
if (!_isPidAlive(info.pid)) return true;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
function installAgentChattr(dir) {
|
|
92
137
|
dir = dir || getAgentChattrDir();
|
|
93
|
-
|
|
94
|
-
|
|
138
|
+
installAgentChattr.lastError = null;
|
|
139
|
+
const setError = (msg) => { installAgentChattr.lastError = msg; return null; };
|
|
140
|
+
|
|
141
|
+
// --- Per-target lock to prevent concurrent clones from corrupting each
|
|
142
|
+
// other when two projects (or two web tabs) launch simultaneously. Lock
|
|
143
|
+
// file lives next to the install dir so it's scoped per-target.
|
|
144
|
+
const lockFile = `${dir}.install.lock`;
|
|
145
|
+
try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
|
|
146
|
+
catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
|
|
147
|
+
|
|
148
|
+
let acquired = false;
|
|
149
|
+
const deadline = Date.now() + INSTALL_LOCK_WAIT_TOTAL_MS;
|
|
150
|
+
while (!acquired) {
|
|
151
|
+
try {
|
|
152
|
+
// Atomic create: fails if file already exists, no TOCTOU race.
|
|
153
|
+
fs.writeFileSync(lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
|
|
154
|
+
acquired = true;
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.code !== "EEXIST") return setError(`Cannot create install lock ${lockFile}: ${e.message}`);
|
|
157
|
+
// Reclaim if the existing lock is stale (crashed pid or too old).
|
|
158
|
+
// Use rename → unlink instead of unlink directly: rename is atomic,
|
|
159
|
+
// so only one racing process can move the stale lock aside. The
|
|
160
|
+
// others see ENOENT and just retry the wx create. Without this,
|
|
161
|
+
// two processes could both observe the same stale lock, both
|
|
162
|
+
// unlink it (one of those unlinks would target the *next* lock
|
|
163
|
+
// freshly acquired by a third process), and both proceed past the
|
|
164
|
+
// gate concurrently — see review on quadwork#193.
|
|
165
|
+
if (_isLockStale(lockFile)) {
|
|
166
|
+
const sideline = `${lockFile}.stale.${process.pid}.${Date.now()}`;
|
|
167
|
+
try {
|
|
168
|
+
fs.renameSync(lockFile, sideline);
|
|
169
|
+
try { fs.unlinkSync(sideline); } catch {}
|
|
170
|
+
} catch (renameErr) {
|
|
171
|
+
// ENOENT: another process already reclaimed it. Anything else:
|
|
172
|
+
// treat as transient and retry — the next iteration will read
|
|
173
|
+
// whatever is at lockFile now and decide again.
|
|
174
|
+
if (renameErr.code !== "ENOENT") {
|
|
175
|
+
return setError(`Cannot reclaim stale lock ${lockFile}: ${renameErr.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Live peer install in progress. After it finishes, the install
|
|
181
|
+
// is likely already done — caller will see a fully-installed path
|
|
182
|
+
// on the next call. While waiting, poll until the lock disappears
|
|
183
|
+
// or we hit the wait deadline.
|
|
184
|
+
if (Date.now() >= deadline) {
|
|
185
|
+
const info = _readLock(lockFile) || { pid: "?", ts: 0 };
|
|
186
|
+
return setError(`Another install is in progress at ${dir} (pid ${info.pid}); timed out after ${INSTALL_LOCK_WAIT_TOTAL_MS}ms. Re-run after it finishes, or remove ${lockFile} if stale.`);
|
|
187
|
+
}
|
|
188
|
+
// Synchronous sleep — installAgentChattr is itself synchronous and
|
|
189
|
+
// is called from the CLI wizard, where blocking is acceptable.
|
|
190
|
+
// Use execSync('sleep') instead of a busy-wait so we don't pin a CPU.
|
|
191
|
+
try { require("child_process").execSync(`sleep ${INSTALL_LOCK_POLL_MS / 1000}`); }
|
|
192
|
+
catch { /* sleep interrupted; loop will recheck */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return _installAgentChattrLocked(dir, setError);
|
|
198
|
+
} finally {
|
|
199
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _installAgentChattrLocked(dir, setError) {
|
|
204
|
+
const runPy = path.join(dir, "run.py");
|
|
205
|
+
const venvPython = path.join(dir, ".venv", "bin", "python");
|
|
206
|
+
let venvJustCreated = false;
|
|
207
|
+
|
|
208
|
+
// 1. Clone if run.py is missing.
|
|
209
|
+
if (!fs.existsSync(runPy)) {
|
|
95
210
|
if (fs.existsSync(dir)) {
|
|
96
|
-
|
|
97
|
-
|
|
211
|
+
let entries;
|
|
212
|
+
try { entries = fs.readdirSync(dir); }
|
|
213
|
+
catch (e) { return setError(`Cannot read ${dir}: ${e.message}`); }
|
|
214
|
+
const isEmpty = entries.length === 0;
|
|
98
215
|
if (isEmpty) {
|
|
99
|
-
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
216
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
217
|
+
catch (e) { return setError(`Cannot remove empty dir ${dir}: ${e.message}`); }
|
|
100
218
|
} else if (fs.existsSync(path.join(dir, ".git"))) {
|
|
101
|
-
//
|
|
219
|
+
// Only remove if origin remote positively identifies this as agentchattr.
|
|
102
220
|
const remote = run(`git -C "${dir}" remote get-url origin 2>/dev/null`);
|
|
103
221
|
if (remote && remote.includes("agentchattr")) {
|
|
104
|
-
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
222
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
223
|
+
catch (e) { return setError(`Cannot remove failed clone at ${dir}: ${e.message}`); }
|
|
105
224
|
} else {
|
|
106
|
-
|
|
107
|
-
return null;
|
|
225
|
+
return setError(`Refusing to overwrite ${dir}: contains a non-AgentChattr git repo`);
|
|
108
226
|
}
|
|
109
227
|
} else {
|
|
110
|
-
|
|
111
|
-
return null;
|
|
228
|
+
return setError(`Refusing to overwrite ${dir}: directory exists with unrelated content`);
|
|
112
229
|
}
|
|
113
230
|
}
|
|
114
|
-
|
|
231
|
+
// Ensure parent exists before clone (supports arbitrary nested paths).
|
|
232
|
+
try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
|
|
233
|
+
catch (e) { return setError(`Cannot create parent of ${dir}: ${e.message}`); }
|
|
115
234
|
const cloneResult = run(`git clone "${AGENTCHATTR_REPO}" "${dir}" 2>&1`, { timeout: 60000 });
|
|
116
|
-
if (cloneResult === null
|
|
235
|
+
if (cloneResult === null) return setError(`git clone of ${AGENTCHATTR_REPO} into ${dir} failed`);
|
|
236
|
+
if (!fs.existsSync(runPy)) return setError(`Clone completed but run.py missing at ${dir}`);
|
|
117
237
|
}
|
|
118
|
-
|
|
119
|
-
|
|
238
|
+
|
|
239
|
+
// 2. Create venv if missing.
|
|
120
240
|
if (!fs.existsSync(venvPython)) {
|
|
121
241
|
const venvResult = run(`python3 -m venv "${path.join(dir, ".venv")}" 2>&1`, { timeout: 60000 });
|
|
122
|
-
if (venvResult === null) return
|
|
242
|
+
if (venvResult === null) return setError(`python3 -m venv failed at ${dir}/.venv (is python3 installed?)`);
|
|
243
|
+
if (!fs.existsSync(venvPython)) return setError(`venv created but ${venvPython} missing`);
|
|
244
|
+
venvJustCreated = true;
|
|
123
245
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
246
|
+
|
|
247
|
+
// 3. Install requirements only when the venv was just (re)created.
|
|
248
|
+
// This makes re-running on a fully-installed path a true no-op.
|
|
249
|
+
if (venvJustCreated) {
|
|
250
|
+
const reqFile = path.join(dir, "requirements.txt");
|
|
251
|
+
if (fs.existsSync(reqFile)) {
|
|
252
|
+
const pipResult = run(`"${venvPython}" -m pip install -r "${reqFile}" 2>&1`, { timeout: 120000 });
|
|
253
|
+
if (pipResult === null) return setError(`pip install -r ${reqFile} failed`);
|
|
254
|
+
}
|
|
128
255
|
}
|
|
129
256
|
return dir;
|
|
130
257
|
}
|
|
258
|
+
installAgentChattr.lastError = null;
|
|
131
259
|
|
|
132
260
|
/**
|
|
133
261
|
* Get spawn args for launching AgentChattr from its cloned directory.
|
|
@@ -725,6 +853,8 @@ async function setupAgents(rl, repo) {
|
|
|
725
853
|
seedContent = seedContent.replace(/\{\{reviewer_github_user\}\}/g, "");
|
|
726
854
|
seedContent = seedContent.replace(/\{\{reviewer_token_path\}\}/g, "");
|
|
727
855
|
}
|
|
856
|
+
// Batch 25 / #205: substitute the per-project queue file path.
|
|
857
|
+
seedContent = seedContent.replace(/\{\{project_name\}\}/g, projectName);
|
|
728
858
|
fs.writeFileSync(seedDst, seedContent);
|
|
729
859
|
}
|
|
730
860
|
}
|
|
@@ -801,25 +931,33 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
|
|
|
801
931
|
fs.writeFileSync(configTomlPath, tomlContent);
|
|
802
932
|
ok(`Wrote ${configTomlPath}`);
|
|
803
933
|
|
|
804
|
-
//
|
|
805
|
-
|
|
934
|
+
// Phase 2C / #181: clone AgentChattr per-project at
|
|
935
|
+
// ~/.quadwork/{project_id}/agentchattr/. AgentChattr's run.py loads
|
|
936
|
+
// ROOT/config.toml, so each project needs its own clone to avoid
|
|
937
|
+
// multi-instance port conflicts (see master #181). The path is the
|
|
938
|
+
// same one writeQuadWorkConfig() persists in project.agentchattr_dir.
|
|
939
|
+
const perProjectDir = path.join(CONFIG_DIR, setup.projectName, "agentchattr");
|
|
940
|
+
let acDir = findAgentChattr(perProjectDir);
|
|
806
941
|
let acAvailable = !!acDir;
|
|
807
942
|
if (!acAvailable && !skipInstall) {
|
|
808
|
-
const acSpinner = spinner(
|
|
809
|
-
const installResult = installAgentChattr();
|
|
943
|
+
const acSpinner = spinner(`Setting up AgentChattr at ${perProjectDir}...`);
|
|
944
|
+
const installResult = installAgentChattr(perProjectDir);
|
|
810
945
|
if (installResult) {
|
|
811
946
|
acSpinner.stop(true);
|
|
947
|
+
acDir = installResult;
|
|
812
948
|
acAvailable = true;
|
|
813
949
|
} else {
|
|
814
950
|
acSpinner.stop(false);
|
|
815
|
-
|
|
951
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
952
|
+
warn(`AgentChattr install failed at ${perProjectDir}: ${reason}`);
|
|
953
|
+
warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
|
|
816
954
|
}
|
|
817
955
|
}
|
|
818
956
|
|
|
819
957
|
// Start AgentChattr server (only if installed)
|
|
820
958
|
if (acAvailable) {
|
|
821
959
|
log("Starting AgentChattr server...");
|
|
822
|
-
const acSpawn = chattrSpawnArgs(
|
|
960
|
+
const acSpawn = chattrSpawnArgs(acDir, ["--config", configTomlPath]);
|
|
823
961
|
if (acSpawn) {
|
|
824
962
|
const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
|
|
825
963
|
cwd: acSpawn.cwd,
|
|
@@ -836,14 +974,14 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
|
|
|
836
974
|
const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
|
|
837
975
|
fs.writeFileSync(pidFile, String(acProc.pid));
|
|
838
976
|
} else {
|
|
839
|
-
warn("Could not start AgentChattr — check logs in " + (
|
|
977
|
+
warn("Could not start AgentChattr — check logs in " + (acDir || perProjectDir));
|
|
840
978
|
}
|
|
841
979
|
} else {
|
|
842
980
|
warn("AgentChattr run.py not found — skipping auto-start.");
|
|
843
981
|
}
|
|
844
982
|
} else {
|
|
845
983
|
warn("AgentChattr not installed — skipping auto-start.");
|
|
846
|
-
log(` → Install: git clone ${AGENTCHATTR_REPO} ${
|
|
984
|
+
log(` → Install: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
|
|
847
985
|
}
|
|
848
986
|
|
|
849
987
|
return configTomlPath;
|
|
@@ -1006,6 +1144,30 @@ bridge_sender = "telegram-bridge"
|
|
|
1006
1144
|
|
|
1007
1145
|
// ─── Write QuadWork Config ──────────────────────────────────────────────────
|
|
1008
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Seed ~/.quadwork/{projectName}/OVERNIGHT-QUEUE.md from templates/.
|
|
1149
|
+
* Idempotent — never overwrites an existing file so user and Head
|
|
1150
|
+
* agent edits are preserved across re-runs.
|
|
1151
|
+
*/
|
|
1152
|
+
function writeOvernightQueueFile(projectName, repo) {
|
|
1153
|
+
const queueDir = path.join(CONFIG_DIR, projectName);
|
|
1154
|
+
const queuePath = path.join(queueDir, "OVERNIGHT-QUEUE.md");
|
|
1155
|
+
if (fs.existsSync(queuePath)) return false;
|
|
1156
|
+
try { fs.mkdirSync(queueDir, { recursive: true }); }
|
|
1157
|
+
catch (e) { warn(`Could not create ${queueDir}: ${e.message}`); return false; }
|
|
1158
|
+
const templatePath = path.join(TEMPLATES_DIR, "OVERNIGHT-QUEUE.md");
|
|
1159
|
+
if (!fs.existsSync(templatePath)) {
|
|
1160
|
+
warn(`OVERNIGHT-QUEUE.md template missing at ${templatePath}`);
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
let content = fs.readFileSync(templatePath, "utf-8");
|
|
1164
|
+
content = content.replace(/\{\{project_name\}\}/g, projectName || "");
|
|
1165
|
+
content = content.replace(/\{\{repo\}\}/g, repo || "");
|
|
1166
|
+
fs.writeFileSync(queuePath, content);
|
|
1167
|
+
ok(`Wrote ${queuePath}`);
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1009
1171
|
function writeQuadWorkConfig(setup) {
|
|
1010
1172
|
header("Writing QuadWork Config");
|
|
1011
1173
|
|
|
@@ -1061,6 +1223,15 @@ function writeQuadWorkConfig(setup) {
|
|
|
1061
1223
|
project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
|
|
1062
1224
|
project.mcp_http_port = mcp_http;
|
|
1063
1225
|
project.mcp_sse_port = mcp_sse;
|
|
1226
|
+
// Per-project AgentChattr clone path (Option B / #181). Each project gets
|
|
1227
|
+
// its own clone so AgentChattr's ROOT/config.toml lookup picks up the right
|
|
1228
|
+
// ports — see master ticket #181.
|
|
1229
|
+
project.agentchattr_dir = path.join(os.homedir(), ".quadwork", setup.projectName, "agentchattr");
|
|
1230
|
+
|
|
1231
|
+
// Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
|
|
1232
|
+
// ~/.quadwork/{id}/OVERNIGHT-QUEUE.md. Idempotent — if the file
|
|
1233
|
+
// already exists, preserve the user's / Head agent's edits.
|
|
1234
|
+
writeOvernightQueueFile(setup.projectName, setup.repo);
|
|
1064
1235
|
|
|
1065
1236
|
// Upsert project
|
|
1066
1237
|
if (existingIdx >= 0) config.projects[existingIdx] = project;
|
|
@@ -1102,33 +1273,29 @@ async function cmdInit() {
|
|
|
1102
1273
|
writeConfig(config);
|
|
1103
1274
|
ok(`Wrote ${CONFIG_PATH}`);
|
|
1104
1275
|
|
|
1105
|
-
// Step 3: Start server
|
|
1276
|
+
// Step 3: Start server in the foreground (Batch 25 / #203).
|
|
1277
|
+
//
|
|
1278
|
+
// Previously cmdInit spawned the server detached and exited, which
|
|
1279
|
+
// left users without logs, without a clear stop story, and
|
|
1280
|
+
// inconsistent with `npx quadwork start` (#169). Now we print the
|
|
1281
|
+
// welcome banner first, schedule the browser open, close the
|
|
1282
|
+
// wizard readline, and then require() the server so it runs in
|
|
1283
|
+
// the user's terminal — Ctrl+C stops it cleanly via the SIGINT
|
|
1284
|
+
// handler below (same pattern cmdStart uses).
|
|
1106
1285
|
header("Step 3: Starting Dashboard");
|
|
1107
1286
|
const quadworkDir = path.join(__dirname, "..");
|
|
1108
1287
|
const serverDir = path.join(quadworkDir, "server");
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
detached: true,
|
|
1114
|
-
env: { ...process.env },
|
|
1115
|
-
});
|
|
1116
|
-
server.unref();
|
|
1117
|
-
if (server.pid) {
|
|
1118
|
-
serverPid = server.pid;
|
|
1119
|
-
ok(`Server started (PID: ${serverPid})`);
|
|
1120
|
-
const pidFile = path.join(CONFIG_DIR, "server.pid");
|
|
1121
|
-
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1122
|
-
fs.writeFileSync(pidFile, String(serverPid));
|
|
1123
|
-
}
|
|
1124
|
-
} else {
|
|
1125
|
-
warn("Server not found — run from the quadwork directory");
|
|
1288
|
+
if (!fs.existsSync(path.join(serverDir, "index.js"))) {
|
|
1289
|
+
fail("Server not found. Run from the quadwork directory.");
|
|
1290
|
+
rl.close();
|
|
1291
|
+
process.exit(1);
|
|
1126
1292
|
}
|
|
1127
1293
|
|
|
1128
|
-
// Done — celebratory welcome
|
|
1129
1294
|
const dashPort = parseInt(port, 10) || 8400;
|
|
1130
1295
|
const dashboardUrl = `http://127.0.0.1:${dashPort}`;
|
|
1131
1296
|
|
|
1297
|
+
// Celebratory welcome (printed BEFORE the server takes over stdout
|
|
1298
|
+
// so it stays visible at the top of the scrollback).
|
|
1132
1299
|
console.log("");
|
|
1133
1300
|
console.log(` ${c.cyan}${c.bold}╔══════════════════════════════════════════════════════════╗${c.reset}`);
|
|
1134
1301
|
console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
|
|
@@ -1145,12 +1312,8 @@ async function cmdInit() {
|
|
|
1145
1312
|
console.log(` ${c.cyan}${c.bold}║${c.reset} ${c.cyan}${c.bold}║${c.reset}`);
|
|
1146
1313
|
console.log(` ${c.cyan}${c.bold}╚══════════════════════════════════════════════════════════╝${c.reset}`);
|
|
1147
1314
|
console.log("");
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
} else {
|
|
1151
|
-
console.log(` ${c.yellow}*${c.reset} Server not started — run ${c.dim}npx quadwork start${c.reset} to launch`);
|
|
1152
|
-
}
|
|
1153
|
-
console.log(` ${c.green}*${c.reset} Config saved to ${c.dim}${CONFIG_PATH}${c.reset}`);
|
|
1315
|
+
console.log(` ${c.green}*${c.reset} Dashboard: ${c.cyan}${dashboardUrl}${c.reset}`);
|
|
1316
|
+
console.log(` ${c.green}*${c.reset} Config: ${c.dim}${CONFIG_PATH}${c.reset}`);
|
|
1154
1317
|
console.log("");
|
|
1155
1318
|
console.log(` ${c.cyan}${c.bold}--- Create Your First Project ---${c.reset}`);
|
|
1156
1319
|
console.log("");
|
|
@@ -1162,19 +1325,40 @@ async function cmdInit() {
|
|
|
1162
1325
|
console.log(` ${c.dim}2.${c.reset} Pick models for each agent`);
|
|
1163
1326
|
console.log(` ${c.dim}3.${c.reset} Hit Start — your team takes it from there`);
|
|
1164
1327
|
console.log("");
|
|
1165
|
-
console.log(` ${c.dim}
|
|
1166
|
-
console.log(` ${c.dim}npx --yes quadwork start${c.reset} — start the dashboard (Ctrl+C to stop)`);
|
|
1167
|
-
console.log("");
|
|
1168
|
-
console.log(` ${c.green}${c.bold}Happy shipping!${c.reset}`);
|
|
1328
|
+
console.log(` ${c.green}${c.bold}Happy shipping!${c.reset} ${c.dim}(Press Ctrl+C to stop.)${c.reset}`);
|
|
1169
1329
|
console.log("");
|
|
1170
1330
|
|
|
1171
|
-
//
|
|
1331
|
+
// Close the wizard readline before requiring the server, otherwise
|
|
1332
|
+
// stdin stays in raw/line-buffered mode and swallows Ctrl+C.
|
|
1333
|
+
rl.close();
|
|
1334
|
+
|
|
1335
|
+
// Schedule browser open after the server has had a moment to bind.
|
|
1172
1336
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1173
1337
|
setTimeout(() => {
|
|
1174
1338
|
try { execSync(`${openCmd} ${dashboardUrl}/setup`, { stdio: "ignore" }); } catch {}
|
|
1175
1339
|
}, 1500);
|
|
1176
1340
|
|
|
1177
|
-
|
|
1341
|
+
// Run the server in the foreground. require() starts the express
|
|
1342
|
+
// listener in this process, so cmdInit stays alive until Ctrl+C.
|
|
1343
|
+
// Capture the exports so the SIGINT handler can ask the server
|
|
1344
|
+
// to SIGTERM any AgentChattr children it spawned after init (the
|
|
1345
|
+
// user creates a project in /setup and clicks Start → the
|
|
1346
|
+
// dashboard launches python run.py as a detached child and only
|
|
1347
|
+
// the server knows its pid, via its in-memory `chattrProcesses`
|
|
1348
|
+
// Map).
|
|
1349
|
+
const serverExports = require(path.join(serverDir, "index.js"));
|
|
1350
|
+
|
|
1351
|
+
// Graceful shutdown on Ctrl+C. Kill any dashboard-spawned
|
|
1352
|
+
// AgentChattr children first, then exit so the port is released
|
|
1353
|
+
// and no python is orphaned.
|
|
1354
|
+
process.on("SIGINT", () => {
|
|
1355
|
+
console.log("");
|
|
1356
|
+
log("Shutting down...");
|
|
1357
|
+
try { serverExports && serverExports.shutdownChattrProcesses && serverExports.shutdownChattrProcesses(); }
|
|
1358
|
+
catch (e) { warn(`shutdownChattrProcesses failed: ${e.message}`); }
|
|
1359
|
+
ok("Stopped.");
|
|
1360
|
+
process.exit(0);
|
|
1361
|
+
});
|
|
1178
1362
|
} catch (err) {
|
|
1179
1363
|
fail(err.message);
|
|
1180
1364
|
rl.close();
|
|
@@ -1184,6 +1368,118 @@ async function cmdInit() {
|
|
|
1184
1368
|
|
|
1185
1369
|
// ─── Start Command ──────────────────────────────────────────────────────────
|
|
1186
1370
|
|
|
1371
|
+
/**
|
|
1372
|
+
* Phase 3 / #181 sub-G: migrate legacy v1 projects to per-project clones.
|
|
1373
|
+
*
|
|
1374
|
+
* Runs eagerly at the top of cmdStart() so users see clear progress before
|
|
1375
|
+
* any agents launch. For each project that doesn't yet have a working
|
|
1376
|
+
* per-project clone:
|
|
1377
|
+
* 1. Compute perProjectDir = ~/.quadwork/{project_id}/agentchattr
|
|
1378
|
+
* 2. installAgentChattr(perProjectDir) — idempotent (#183 + #187)
|
|
1379
|
+
* 3. Copy the existing legacy <working_dir>/agentchattr/config.toml into
|
|
1380
|
+
* the new clone ROOT if it exists. AgentChattr's run.py reads
|
|
1381
|
+
* ROOT/config.toml from the clone dir, so this is what makes the
|
|
1382
|
+
* project actually start from its own clone.
|
|
1383
|
+
* 4. Set project.agentchattr_dir on the config entry and persist.
|
|
1384
|
+
*
|
|
1385
|
+
* Idempotent: if a project already has a working per-project clone with a
|
|
1386
|
+
* config.toml at the ROOT and agentchattr_dir set, it is skipped silently.
|
|
1387
|
+
* The legacy ~/.quadwork/agentchattr/ install is left alone — cleanup is
|
|
1388
|
+
* sub-H (#189).
|
|
1389
|
+
*
|
|
1390
|
+
* The migration never touches worktrees, repo content, or token files;
|
|
1391
|
+
* only the per-project AgentChattr install dir and config.json.
|
|
1392
|
+
*/
|
|
1393
|
+
function migrateLegacyProjects(config) {
|
|
1394
|
+
if (!config.projects || config.projects.length === 0) return false;
|
|
1395
|
+
|
|
1396
|
+
const needsMigration = config.projects.filter((p) => {
|
|
1397
|
+
if (!p.id) return false;
|
|
1398
|
+
const target = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
|
|
1399
|
+
const hasClone = fs.existsSync(path.join(target, "run.py")) &&
|
|
1400
|
+
fs.existsSync(path.join(target, ".venv", "bin", "python"));
|
|
1401
|
+
const hasToml = fs.existsSync(path.join(target, "config.toml"));
|
|
1402
|
+
const hasField = !!p.agentchattr_dir;
|
|
1403
|
+
return !(hasField && hasClone && hasToml);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
if (needsMigration.length === 0) return false;
|
|
1407
|
+
|
|
1408
|
+
header("Migrating legacy projects to per-project AgentChattr clones");
|
|
1409
|
+
let mutated = false;
|
|
1410
|
+
for (const project of needsMigration) {
|
|
1411
|
+
const perProjectDir = path.join(CONFIG_DIR, project.id, "agentchattr");
|
|
1412
|
+
log(` ${project.id} → ${perProjectDir}`);
|
|
1413
|
+
|
|
1414
|
+
// 1. Install (idempotent — no-op if clone is already valid).
|
|
1415
|
+
if (!findAgentChattr(perProjectDir)) {
|
|
1416
|
+
const acSpinner = spinner(` Cloning AgentChattr for ${project.id}...`);
|
|
1417
|
+
const installResult = installAgentChattr(perProjectDir);
|
|
1418
|
+
if (!installResult) {
|
|
1419
|
+
acSpinner.stop(false);
|
|
1420
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
1421
|
+
warn(` Migration failed for ${project.id}: ${reason}`);
|
|
1422
|
+
warn(` ${project.id} will keep using the legacy global install until this is resolved.`);
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
acSpinner.stop(true);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// 2. Seed config.toml at the clone ROOT from the legacy in-worktree
|
|
1429
|
+
// location if present. Do not overwrite an existing per-project
|
|
1430
|
+
// config.toml — re-running the migration must be a no-op.
|
|
1431
|
+
//
|
|
1432
|
+
// If the legacy toml exists but the copy fails, we MUST NOT persist
|
|
1433
|
+
// agentchattr_dir — otherwise #186's resolver would switch this
|
|
1434
|
+
// project to a clone that lacks the project's real ports, and
|
|
1435
|
+
// AgentChattr would silently start on run.py defaults. Leaving
|
|
1436
|
+
// agentchattr_dir unset keeps the project on the legacy global
|
|
1437
|
+
// install via #186's fallback ladder until the next attempt.
|
|
1438
|
+
const targetToml = path.join(perProjectDir, "config.toml");
|
|
1439
|
+
let tomlReady = fs.existsSync(targetToml);
|
|
1440
|
+
if (!tomlReady && project.working_dir) {
|
|
1441
|
+
const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
|
|
1442
|
+
if (fs.existsSync(legacyToml)) {
|
|
1443
|
+
try {
|
|
1444
|
+
fs.copyFileSync(legacyToml, targetToml);
|
|
1445
|
+
log(` Copied legacy config.toml → ${targetToml}`);
|
|
1446
|
+
tomlReady = true;
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
warn(` Could not copy ${legacyToml}: ${e.message}`);
|
|
1449
|
+
warn(` ${project.id} migration aborted: legacy config.toml not transferred.`);
|
|
1450
|
+
warn(` ${project.id} will keep using the legacy global install via #186 fallback.`);
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
} else {
|
|
1454
|
+
// No legacy toml at all (e.g. user removed it). Refuse to migrate
|
|
1455
|
+
// — without a config.toml at the clone ROOT, run.py would start
|
|
1456
|
+
// on built-in defaults and bind to the wrong ports.
|
|
1457
|
+
warn(` ${project.id} has no legacy config.toml at ${legacyToml}; skipping migration.`);
|
|
1458
|
+
warn(` Re-run setup to regenerate config.toml, then 'quadwork start' will retry migration.`);
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (!tomlReady) {
|
|
1463
|
+
warn(` ${project.id} migration aborted: no config.toml at ${targetToml}.`);
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// 3. Persist agentchattr_dir on the project entry — only after the
|
|
1468
|
+
// clone has run.py + venv + config.toml all in place.
|
|
1469
|
+
if (project.agentchattr_dir !== perProjectDir) {
|
|
1470
|
+
project.agentchattr_dir = perProjectDir;
|
|
1471
|
+
mutated = true;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (mutated) {
|
|
1476
|
+
try { writeConfig(config); ok("Updated config.json with per-project agentchattr_dir entries"); }
|
|
1477
|
+
catch (e) { warn(`Failed to write config.json: ${e.message}`); }
|
|
1478
|
+
}
|
|
1479
|
+
log(" Legacy ~/.quadwork/agentchattr/ left in place; remove via cleanup script (#189).");
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1187
1483
|
function cmdStart() {
|
|
1188
1484
|
console.log("\n QuadWork Start\n");
|
|
1189
1485
|
|
|
@@ -1192,6 +1488,11 @@ function cmdStart() {
|
|
|
1192
1488
|
warn("No projects configured yet. Create one at the setup page.");
|
|
1193
1489
|
}
|
|
1194
1490
|
|
|
1491
|
+
// Phase 3 / #181: migrate legacy single-install projects to their
|
|
1492
|
+
// own per-project clones before any AgentChattr spawn happens.
|
|
1493
|
+
// Idempotent — a no-op once every project already has a working clone.
|
|
1494
|
+
migrateLegacyProjects(config);
|
|
1495
|
+
|
|
1195
1496
|
const quadworkDir = path.join(__dirname, "..");
|
|
1196
1497
|
const port = config.port || 8400;
|
|
1197
1498
|
|
|
@@ -1209,27 +1510,38 @@ function cmdStart() {
|
|
|
1209
1510
|
process.exit(1);
|
|
1210
1511
|
}
|
|
1211
1512
|
|
|
1212
|
-
// Start AgentChattr for each project
|
|
1513
|
+
// Start AgentChattr for each project from its own per-project clone.
|
|
1514
|
+
// Phase 2E / #181: each project entry now has agentchattr_dir, set by
|
|
1515
|
+
// the wizards in #184/#185. Resolve per-project so two projects with
|
|
1516
|
+
// their own clones (and their own ports) can run side by side without
|
|
1517
|
+
// sharing a single global install. Falls back to the legacy global
|
|
1518
|
+
// install dir for v1 entries that have not been migrated yet (#188).
|
|
1213
1519
|
const acPids = [];
|
|
1214
|
-
const
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1520
|
+
const legacyAcDir = findAgentChattr(config.agentchattr_dir);
|
|
1521
|
+
for (const project of config.projects) {
|
|
1522
|
+
if (!project.working_dir) continue;
|
|
1523
|
+
const projectAcDir = findAgentChattr(project.agentchattr_dir) || legacyAcDir;
|
|
1524
|
+
if (!projectAcDir) continue;
|
|
1525
|
+
// config.toml lives at the clone ROOT for new projects; legacy v1
|
|
1526
|
+
// setups still keep it under <working_dir>/agentchattr/config.toml.
|
|
1527
|
+
const perProjectToml = path.join(projectAcDir, "config.toml");
|
|
1528
|
+
const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
|
|
1529
|
+
const configToml = fs.existsSync(perProjectToml)
|
|
1530
|
+
? perProjectToml
|
|
1531
|
+
: (fs.existsSync(legacyToml) ? legacyToml : null);
|
|
1532
|
+
if (!configToml) continue;
|
|
1533
|
+
const acSpawn = chattrSpawnArgs(projectAcDir, ["--config", configToml]);
|
|
1534
|
+
if (!acSpawn) continue;
|
|
1535
|
+
const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
|
|
1536
|
+
cwd: acSpawn.cwd,
|
|
1537
|
+
stdio: "ignore",
|
|
1538
|
+
detached: true,
|
|
1539
|
+
});
|
|
1540
|
+
acProc.on("error", () => {});
|
|
1541
|
+
acProc.unref();
|
|
1542
|
+
if (acProc.pid) {
|
|
1543
|
+
ok(`AgentChattr started for ${project.id} from ${projectAcDir} (PID: ${acProc.pid})`);
|
|
1544
|
+
acPids.push(acProc.pid);
|
|
1233
1545
|
}
|
|
1234
1546
|
}
|
|
1235
1547
|
|
|
@@ -1240,13 +1552,25 @@ function cmdStart() {
|
|
|
1240
1552
|
try { execSync(`${openCmd} ${dashboardUrl}`, { stdio: "ignore" }); } catch {}
|
|
1241
1553
|
}, 1500);
|
|
1242
1554
|
|
|
1243
|
-
//
|
|
1555
|
+
// Run server in foreground. Capture exports so the SIGINT handler
|
|
1556
|
+
// can ask the server to SIGTERM its own chattrProcesses Map too
|
|
1557
|
+
// (dashboard-spawned AgentChattr children aren't in cmdStart's
|
|
1558
|
+
// acPids list).
|
|
1559
|
+
log(`Dashboard: ${dashboardUrl}`);
|
|
1560
|
+
log("Press Ctrl+C to stop.\n");
|
|
1561
|
+
const serverExports = require(path.join(serverDir, "index.js"));
|
|
1562
|
+
|
|
1563
|
+
// Graceful shutdown on Ctrl+C — kills cmdStart's own spawned
|
|
1564
|
+
// AgentChattrs AND anything the dashboard spawned via
|
|
1565
|
+
// /api/agentchattr/{id}/start after init.
|
|
1244
1566
|
process.on("SIGINT", () => {
|
|
1245
1567
|
console.log("");
|
|
1246
1568
|
log("Shutting down...");
|
|
1247
1569
|
for (const pid of acPids) {
|
|
1248
1570
|
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
1249
1571
|
}
|
|
1572
|
+
try { serverExports && serverExports.shutdownChattrProcesses && serverExports.shutdownChattrProcesses(); }
|
|
1573
|
+
catch (e) { warn(`shutdownChattrProcesses failed: ${e.message}`); }
|
|
1250
1574
|
ok("Stopped.");
|
|
1251
1575
|
console.log("");
|
|
1252
1576
|
log("To restart:");
|
|
@@ -1254,11 +1578,6 @@ function cmdStart() {
|
|
|
1254
1578
|
console.log("");
|
|
1255
1579
|
process.exit(0);
|
|
1256
1580
|
});
|
|
1257
|
-
|
|
1258
|
-
// Run server in foreground
|
|
1259
|
-
log(`Dashboard: ${dashboardUrl}`);
|
|
1260
|
-
log("Press Ctrl+C to stop.\n");
|
|
1261
|
-
require(path.join(serverDir, "index.js"));
|
|
1262
1581
|
}
|
|
1263
1582
|
|
|
1264
1583
|
// ─── Stop Command ───────────────────────────────────────────────────────────
|
|
@@ -1329,7 +1648,11 @@ async function cmdAddProject() {
|
|
|
1329
1648
|
|
|
1330
1649
|
writeQuadWorkConfig(setup);
|
|
1331
1650
|
|
|
1332
|
-
|
|
1651
|
+
// Phase 2C / #181: config.toml lives at the per-project clone ROOT
|
|
1652
|
+
// because AgentChattr's run.py loads ROOT/config.toml and ignores
|
|
1653
|
+
// --config. Must match the install path used inside
|
|
1654
|
+
// writeAgentChattrConfig(): CONFIG_DIR/{projectName}/agentchattr.
|
|
1655
|
+
const configTomlPath = path.join(CONFIG_DIR, setup.projectName, "agentchattr", "config.toml");
|
|
1333
1656
|
writeAgentChattrConfig(setup, configTomlPath);
|
|
1334
1657
|
|
|
1335
1658
|
header("Project Added");
|
|
@@ -1346,6 +1669,112 @@ async function cmdAddProject() {
|
|
|
1346
1669
|
}
|
|
1347
1670
|
}
|
|
1348
1671
|
|
|
1672
|
+
// ─── Cleanup Command (#181 sub-H) ───────────────────────────────────────────
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Reclaim disk space taken by per-project AgentChattr clones (~77 MB each)
|
|
1676
|
+
* or by the legacy shared install left behind after migration (#188).
|
|
1677
|
+
*
|
|
1678
|
+
* Usage:
|
|
1679
|
+
* npx quadwork cleanup --project <id>
|
|
1680
|
+
* Removes ~/.quadwork/{id}/ and the matching entry from config.json.
|
|
1681
|
+
* Leaves the user's worktrees and source repos completely alone.
|
|
1682
|
+
*
|
|
1683
|
+
* npx quadwork cleanup --legacy
|
|
1684
|
+
* Removes the legacy shared ~/.quadwork/agentchattr/ install. Refuses
|
|
1685
|
+
* to run unless every project in config.json already has its own
|
|
1686
|
+
* working per-project clone (so nothing falls back onto the legacy
|
|
1687
|
+
* install via #186's resolution ladder).
|
|
1688
|
+
*
|
|
1689
|
+
* Both modes prompt for confirmation before deleting.
|
|
1690
|
+
*/
|
|
1691
|
+
async function cmdCleanup() {
|
|
1692
|
+
const args = process.argv.slice(3);
|
|
1693
|
+
const projectFlagIdx = args.indexOf("--project");
|
|
1694
|
+
const projectId = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : null;
|
|
1695
|
+
const legacy = args.includes("--legacy");
|
|
1696
|
+
|
|
1697
|
+
if (!projectId && !legacy) {
|
|
1698
|
+
console.log(`
|
|
1699
|
+
Usage:
|
|
1700
|
+
npx quadwork cleanup --project <id> Remove a project's AgentChattr clone + config entry
|
|
1701
|
+
npx quadwork cleanup --legacy Remove the legacy ~/.quadwork/agentchattr/ install
|
|
1702
|
+
`);
|
|
1703
|
+
process.exit(1);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1707
|
+
try {
|
|
1708
|
+
const config = readConfig();
|
|
1709
|
+
|
|
1710
|
+
// --- Per-project cleanup ---
|
|
1711
|
+
if (projectId) {
|
|
1712
|
+
const idx = (config.projects || []).findIndex((p) => p.id === projectId);
|
|
1713
|
+
const projectDir = path.join(CONFIG_DIR, projectId);
|
|
1714
|
+
if (idx < 0 && !fs.existsSync(projectDir)) {
|
|
1715
|
+
warn(`No project '${projectId}' in config and no directory at ${projectDir}.`);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
header(`Cleanup: ${projectId}`);
|
|
1719
|
+
if (fs.existsSync(projectDir)) log(` Directory: ${projectDir}`);
|
|
1720
|
+
if (idx >= 0) log(` Config entry: ${projectId} (${config.projects[idx].repo || "no repo"})`);
|
|
1721
|
+
log(" Worktrees and source repos will NOT be touched.");
|
|
1722
|
+
const confirm = await askYN(rl, `Delete ${projectDir} and remove the config entry?`, false);
|
|
1723
|
+
if (!confirm) { warn("Aborted."); return; }
|
|
1724
|
+
|
|
1725
|
+
if (fs.existsSync(projectDir)) {
|
|
1726
|
+
try { fs.rmSync(projectDir, { recursive: true, force: true }); ok(`Removed ${projectDir}`); }
|
|
1727
|
+
catch (e) { fail(`Could not remove ${projectDir}: ${e.message}`); return; }
|
|
1728
|
+
}
|
|
1729
|
+
if (idx >= 0) {
|
|
1730
|
+
config.projects.splice(idx, 1);
|
|
1731
|
+
try { writeConfig(config); ok(`Updated ${CONFIG_PATH}`); }
|
|
1732
|
+
catch (e) { fail(`Could not write config: ${e.message}`); return; }
|
|
1733
|
+
}
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// --- Legacy cleanup ---
|
|
1738
|
+
if (legacy) {
|
|
1739
|
+
const legacyDir = path.join(CONFIG_DIR, "agentchattr");
|
|
1740
|
+
if (!fs.existsSync(legacyDir)) {
|
|
1741
|
+
warn(`No legacy install at ${legacyDir}.`);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
header("Cleanup: legacy ~/.quadwork/agentchattr/");
|
|
1745
|
+
|
|
1746
|
+
// Refuse if any project still depends on the legacy install — i.e.
|
|
1747
|
+
// any project without its own working per-project clone (run.py +
|
|
1748
|
+
// venv + config.toml at ROOT). Mirrors #186's resolution ladder.
|
|
1749
|
+
const stillDepends = [];
|
|
1750
|
+
for (const p of config.projects || []) {
|
|
1751
|
+
if (!p.id) continue;
|
|
1752
|
+
const dir = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
|
|
1753
|
+
const ok = fs.existsSync(path.join(dir, "run.py")) &&
|
|
1754
|
+
fs.existsSync(path.join(dir, ".venv", "bin", "python")) &&
|
|
1755
|
+
fs.existsSync(path.join(dir, "config.toml"));
|
|
1756
|
+
if (!ok) stillDepends.push(p.id);
|
|
1757
|
+
}
|
|
1758
|
+
if (stillDepends.length > 0) {
|
|
1759
|
+
fail(`Refusing to remove legacy install — these projects still depend on it:`);
|
|
1760
|
+
for (const id of stillDepends) console.log(` - ${id}`);
|
|
1761
|
+
warn(`Run 'npx quadwork start' to migrate them (#188), then re-run cleanup --legacy.`);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
log(` Directory: ${legacyDir}`);
|
|
1766
|
+
log(" All projects already have their own per-project clones.");
|
|
1767
|
+
const confirm = await askYN(rl, `Delete ${legacyDir}?`, false);
|
|
1768
|
+
if (!confirm) { warn("Aborted."); return; }
|
|
1769
|
+
|
|
1770
|
+
try { fs.rmSync(legacyDir, { recursive: true, force: true }); ok(`Removed ${legacyDir}`); }
|
|
1771
|
+
catch (e) { fail(`Could not remove ${legacyDir}: ${e.message}`); return; }
|
|
1772
|
+
}
|
|
1773
|
+
} finally {
|
|
1774
|
+
rl.close();
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1349
1778
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1350
1779
|
|
|
1351
1780
|
const command = process.argv[2];
|
|
@@ -1363,6 +1792,9 @@ switch (command) {
|
|
|
1363
1792
|
case "add-project":
|
|
1364
1793
|
cmdAddProject();
|
|
1365
1794
|
break;
|
|
1795
|
+
case "cleanup":
|
|
1796
|
+
cmdCleanup();
|
|
1797
|
+
break;
|
|
1366
1798
|
default:
|
|
1367
1799
|
console.log(`
|
|
1368
1800
|
Usage: quadwork <command>
|
|
@@ -1372,6 +1804,7 @@ switch (command) {
|
|
|
1372
1804
|
start Start the QuadWork dashboard and backend
|
|
1373
1805
|
stop Stop all QuadWork processes
|
|
1374
1806
|
add-project Add a project via CLI (alternative to web UI /setup)
|
|
1807
|
+
cleanup Reclaim disk space (--project <id> or --legacy)
|
|
1375
1808
|
|
|
1376
1809
|
Workflow:
|
|
1377
1810
|
1. npx quadwork init — one-time global setup, opens dashboard
|
|
@@ -1382,6 +1815,8 @@ switch (command) {
|
|
|
1382
1815
|
npx quadwork init
|
|
1383
1816
|
npx quadwork start
|
|
1384
1817
|
npx quadwork stop
|
|
1818
|
+
npx quadwork cleanup --project my-project
|
|
1819
|
+
npx quadwork cleanup --legacy
|
|
1385
1820
|
`);
|
|
1386
1821
|
if (command) process.exit(1);
|
|
1387
1822
|
}
|