quadwork 1.0.16 → 1.1.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 +445 -53
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +2 -2
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +2 -2
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/0caq73v0knw_w.js +1 -0
- package/out/_next/static/chunks/10b3c4k.q.yw..css +2 -0
- package/out/_not-found/__next._full.txt +2 -2
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +2 -2
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +2 -2
- package/out/app-shell/__next._full.txt +2 -2
- package/out/app-shell/__next._head.txt +1 -1
- package/out/app-shell/__next._index.txt +2 -2
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
- package/out/app-shell/__next.app-shell.txt +1 -1
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/project/_/__next._full.txt +2 -2
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +2 -2
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/memory/__next._full.txt +2 -2
- package/out/project/_/memory/__next._head.txt +1 -1
- package/out/project/_/memory/__next._index.txt +2 -2
- package/out/project/_/memory/__next._tree.txt +2 -2
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.txt +1 -1
- package/out/project/_/memory/__next.project.txt +1 -1
- package/out/project/_/memory.html +1 -1
- package/out/project/_/memory.txt +2 -2
- package/out/project/_/queue/__next._full.txt +2 -2
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +2 -2
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +2 -2
- package/out/project/_.html +1 -1
- package/out/project/_.txt +2 -2
- package/out/settings/__next._full.txt +2 -2
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +2 -2
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +1 -1
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +2 -2
- package/out/setup/__next._full.txt +3 -3
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +2 -2
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +2 -2
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +3 -3
- package/package.json +1 -1
- package/server/config.js +22 -1
- package/server/index.js +201 -14
- package/server/install-agentchattr.js +215 -0
- package/server/routes.js +65 -19
- package/out/_next/static/chunks/0ahp74n0wkel0.js +0 -1
- package/out/_next/static/chunks/0s8jbc4nxw6y6.css +0 -2
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_buildManifest.js +0 -0
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{GOOT2ox5oH-rTFhgq8-MK → zx5_zAjM3qhPvkFrygZp8}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -64,9 +64,37 @@ Every task follows a GitHub workflow: Issue → Branch → PR → 2 Reviews →
|
|
|
64
64
|
| `npx quadwork init` | One-time setup — installs prerequisites and opens the dashboard |
|
|
65
65
|
| `npx quadwork start` | Start the dashboard server |
|
|
66
66
|
| `npx quadwork stop` | Stop all processes |
|
|
67
|
+
| `npx quadwork cleanup --project <id>` | Remove a project's AgentChattr clone and config entry |
|
|
68
|
+
| `npx quadwork cleanup --legacy` | Remove the legacy `~/.quadwork/agentchattr/` install after migration |
|
|
67
69
|
|
|
68
70
|
After init, create projects from the web UI at `http://127.0.0.1:8400/setup`.
|
|
69
71
|
|
|
72
|
+
### Disk usage
|
|
73
|
+
|
|
74
|
+
Each project gets its own AgentChattr clone at `~/.quadwork/{project_id}/agentchattr/` (~77 MB). For example:
|
|
75
|
+
|
|
76
|
+
| Projects | Disk |
|
|
77
|
+
|---------:|-----:|
|
|
78
|
+
| 1 | ~77 MB |
|
|
79
|
+
| 5 | ~385 MB |
|
|
80
|
+
| 10 | ~770 MB |
|
|
81
|
+
|
|
82
|
+
Per-project clones are necessary so multiple projects can run AgentChattr simultaneously without port conflicts (each clone has its own `config.toml`, ports, and data directory).
|
|
83
|
+
|
|
84
|
+
To free space, delete unused projects from the dashboard or run:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npx quadwork cleanup --project <id>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Existing v1 users are auto-migrated to per-project clones on the next `npx quadwork start`. After all projects are migrated, the legacy shared install can be removed with:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npx quadwork cleanup --legacy
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`cleanup --legacy` refuses to run unless every project already has its own working per-project clone, so it can never strand a project on a missing install.
|
|
97
|
+
|
|
70
98
|
## Configuration
|
|
71
99
|
|
|
72
100
|
Config is stored at `~/.quadwork/config.json`:
|
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.
|
|
@@ -801,25 +929,33 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
|
|
|
801
929
|
fs.writeFileSync(configTomlPath, tomlContent);
|
|
802
930
|
ok(`Wrote ${configTomlPath}`);
|
|
803
931
|
|
|
804
|
-
//
|
|
805
|
-
|
|
932
|
+
// Phase 2C / #181: clone AgentChattr per-project at
|
|
933
|
+
// ~/.quadwork/{project_id}/agentchattr/. AgentChattr's run.py loads
|
|
934
|
+
// ROOT/config.toml, so each project needs its own clone to avoid
|
|
935
|
+
// multi-instance port conflicts (see master #181). The path is the
|
|
936
|
+
// same one writeQuadWorkConfig() persists in project.agentchattr_dir.
|
|
937
|
+
const perProjectDir = path.join(CONFIG_DIR, setup.projectName, "agentchattr");
|
|
938
|
+
let acDir = findAgentChattr(perProjectDir);
|
|
806
939
|
let acAvailable = !!acDir;
|
|
807
940
|
if (!acAvailable && !skipInstall) {
|
|
808
|
-
const acSpinner = spinner(
|
|
809
|
-
const installResult = installAgentChattr();
|
|
941
|
+
const acSpinner = spinner(`Setting up AgentChattr at ${perProjectDir}...`);
|
|
942
|
+
const installResult = installAgentChattr(perProjectDir);
|
|
810
943
|
if (installResult) {
|
|
811
944
|
acSpinner.stop(true);
|
|
945
|
+
acDir = installResult;
|
|
812
946
|
acAvailable = true;
|
|
813
947
|
} else {
|
|
814
948
|
acSpinner.stop(false);
|
|
815
|
-
|
|
949
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
950
|
+
warn(`AgentChattr install failed at ${perProjectDir}: ${reason}`);
|
|
951
|
+
warn(`Install manually: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
|
|
816
952
|
}
|
|
817
953
|
}
|
|
818
954
|
|
|
819
955
|
// Start AgentChattr server (only if installed)
|
|
820
956
|
if (acAvailable) {
|
|
821
957
|
log("Starting AgentChattr server...");
|
|
822
|
-
const acSpawn = chattrSpawnArgs(
|
|
958
|
+
const acSpawn = chattrSpawnArgs(acDir, ["--config", configTomlPath]);
|
|
823
959
|
if (acSpawn) {
|
|
824
960
|
const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
|
|
825
961
|
cwd: acSpawn.cwd,
|
|
@@ -836,14 +972,14 @@ function writeAgentChattrConfig(setup, configTomlPath, { skipInstall = false } =
|
|
|
836
972
|
const pidFile = path.join(CONFIG_DIR, `agentchattr-${setup.projectName}.pid`);
|
|
837
973
|
fs.writeFileSync(pidFile, String(acProc.pid));
|
|
838
974
|
} else {
|
|
839
|
-
warn("Could not start AgentChattr — check logs in " + (
|
|
975
|
+
warn("Could not start AgentChattr — check logs in " + (acDir || perProjectDir));
|
|
840
976
|
}
|
|
841
977
|
} else {
|
|
842
978
|
warn("AgentChattr run.py not found — skipping auto-start.");
|
|
843
979
|
}
|
|
844
980
|
} else {
|
|
845
981
|
warn("AgentChattr not installed — skipping auto-start.");
|
|
846
|
-
log(` → Install: git clone ${AGENTCHATTR_REPO} ${
|
|
982
|
+
log(` → Install: git clone ${AGENTCHATTR_REPO} ${perProjectDir}`);
|
|
847
983
|
}
|
|
848
984
|
|
|
849
985
|
return configTomlPath;
|
|
@@ -1020,7 +1156,15 @@ function writeQuadWorkConfig(setup) {
|
|
|
1020
1156
|
};
|
|
1021
1157
|
|
|
1022
1158
|
for (const agent of AGENTS) {
|
|
1023
|
-
|
|
1159
|
+
const cmd = (setup.backends && setup.backends[agent]) || setup.backend;
|
|
1160
|
+
const cliBase = cmd.split("/").pop().split(" ")[0];
|
|
1161
|
+
const injectMode = cliBase === "codex" ? "proxy_flag" : cliBase === "gemini" ? "env" : "flag";
|
|
1162
|
+
project.agents[agent] = {
|
|
1163
|
+
cwd: setup.worktrees[agent],
|
|
1164
|
+
command: cmd,
|
|
1165
|
+
auto_approve: true,
|
|
1166
|
+
mcp_inject: injectMode,
|
|
1167
|
+
};
|
|
1024
1168
|
}
|
|
1025
1169
|
|
|
1026
1170
|
if (setup.memoryDir) {
|
|
@@ -1053,6 +1197,10 @@ function writeQuadWorkConfig(setup) {
|
|
|
1053
1197
|
project.agentchattr_token = require("crypto").randomBytes(16).toString("hex");
|
|
1054
1198
|
project.mcp_http_port = mcp_http;
|
|
1055
1199
|
project.mcp_sse_port = mcp_sse;
|
|
1200
|
+
// Per-project AgentChattr clone path (Option B / #181). Each project gets
|
|
1201
|
+
// its own clone so AgentChattr's ROOT/config.toml lookup picks up the right
|
|
1202
|
+
// ports — see master ticket #181.
|
|
1203
|
+
project.agentchattr_dir = path.join(os.homedir(), ".quadwork", setup.projectName, "agentchattr");
|
|
1056
1204
|
|
|
1057
1205
|
// Upsert project
|
|
1058
1206
|
if (existingIdx >= 0) config.projects[existingIdx] = project;
|
|
@@ -1176,6 +1324,118 @@ async function cmdInit() {
|
|
|
1176
1324
|
|
|
1177
1325
|
// ─── Start Command ──────────────────────────────────────────────────────────
|
|
1178
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Phase 3 / #181 sub-G: migrate legacy v1 projects to per-project clones.
|
|
1329
|
+
*
|
|
1330
|
+
* Runs eagerly at the top of cmdStart() so users see clear progress before
|
|
1331
|
+
* any agents launch. For each project that doesn't yet have a working
|
|
1332
|
+
* per-project clone:
|
|
1333
|
+
* 1. Compute perProjectDir = ~/.quadwork/{project_id}/agentchattr
|
|
1334
|
+
* 2. installAgentChattr(perProjectDir) — idempotent (#183 + #187)
|
|
1335
|
+
* 3. Copy the existing legacy <working_dir>/agentchattr/config.toml into
|
|
1336
|
+
* the new clone ROOT if it exists. AgentChattr's run.py reads
|
|
1337
|
+
* ROOT/config.toml from the clone dir, so this is what makes the
|
|
1338
|
+
* project actually start from its own clone.
|
|
1339
|
+
* 4. Set project.agentchattr_dir on the config entry and persist.
|
|
1340
|
+
*
|
|
1341
|
+
* Idempotent: if a project already has a working per-project clone with a
|
|
1342
|
+
* config.toml at the ROOT and agentchattr_dir set, it is skipped silently.
|
|
1343
|
+
* The legacy ~/.quadwork/agentchattr/ install is left alone — cleanup is
|
|
1344
|
+
* sub-H (#189).
|
|
1345
|
+
*
|
|
1346
|
+
* The migration never touches worktrees, repo content, or token files;
|
|
1347
|
+
* only the per-project AgentChattr install dir and config.json.
|
|
1348
|
+
*/
|
|
1349
|
+
function migrateLegacyProjects(config) {
|
|
1350
|
+
if (!config.projects || config.projects.length === 0) return false;
|
|
1351
|
+
|
|
1352
|
+
const needsMigration = config.projects.filter((p) => {
|
|
1353
|
+
if (!p.id) return false;
|
|
1354
|
+
const target = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
|
|
1355
|
+
const hasClone = fs.existsSync(path.join(target, "run.py")) &&
|
|
1356
|
+
fs.existsSync(path.join(target, ".venv", "bin", "python"));
|
|
1357
|
+
const hasToml = fs.existsSync(path.join(target, "config.toml"));
|
|
1358
|
+
const hasField = !!p.agentchattr_dir;
|
|
1359
|
+
return !(hasField && hasClone && hasToml);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
if (needsMigration.length === 0) return false;
|
|
1363
|
+
|
|
1364
|
+
header("Migrating legacy projects to per-project AgentChattr clones");
|
|
1365
|
+
let mutated = false;
|
|
1366
|
+
for (const project of needsMigration) {
|
|
1367
|
+
const perProjectDir = path.join(CONFIG_DIR, project.id, "agentchattr");
|
|
1368
|
+
log(` ${project.id} → ${perProjectDir}`);
|
|
1369
|
+
|
|
1370
|
+
// 1. Install (idempotent — no-op if clone is already valid).
|
|
1371
|
+
if (!findAgentChattr(perProjectDir)) {
|
|
1372
|
+
const acSpinner = spinner(` Cloning AgentChattr for ${project.id}...`);
|
|
1373
|
+
const installResult = installAgentChattr(perProjectDir);
|
|
1374
|
+
if (!installResult) {
|
|
1375
|
+
acSpinner.stop(false);
|
|
1376
|
+
const reason = installAgentChattr.lastError || "unknown error";
|
|
1377
|
+
warn(` Migration failed for ${project.id}: ${reason}`);
|
|
1378
|
+
warn(` ${project.id} will keep using the legacy global install until this is resolved.`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
acSpinner.stop(true);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// 2. Seed config.toml at the clone ROOT from the legacy in-worktree
|
|
1385
|
+
// location if present. Do not overwrite an existing per-project
|
|
1386
|
+
// config.toml — re-running the migration must be a no-op.
|
|
1387
|
+
//
|
|
1388
|
+
// If the legacy toml exists but the copy fails, we MUST NOT persist
|
|
1389
|
+
// agentchattr_dir — otherwise #186's resolver would switch this
|
|
1390
|
+
// project to a clone that lacks the project's real ports, and
|
|
1391
|
+
// AgentChattr would silently start on run.py defaults. Leaving
|
|
1392
|
+
// agentchattr_dir unset keeps the project on the legacy global
|
|
1393
|
+
// install via #186's fallback ladder until the next attempt.
|
|
1394
|
+
const targetToml = path.join(perProjectDir, "config.toml");
|
|
1395
|
+
let tomlReady = fs.existsSync(targetToml);
|
|
1396
|
+
if (!tomlReady && project.working_dir) {
|
|
1397
|
+
const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
|
|
1398
|
+
if (fs.existsSync(legacyToml)) {
|
|
1399
|
+
try {
|
|
1400
|
+
fs.copyFileSync(legacyToml, targetToml);
|
|
1401
|
+
log(` Copied legacy config.toml → ${targetToml}`);
|
|
1402
|
+
tomlReady = true;
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
warn(` Could not copy ${legacyToml}: ${e.message}`);
|
|
1405
|
+
warn(` ${project.id} migration aborted: legacy config.toml not transferred.`);
|
|
1406
|
+
warn(` ${project.id} will keep using the legacy global install via #186 fallback.`);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
} else {
|
|
1410
|
+
// No legacy toml at all (e.g. user removed it). Refuse to migrate
|
|
1411
|
+
// — without a config.toml at the clone ROOT, run.py would start
|
|
1412
|
+
// on built-in defaults and bind to the wrong ports.
|
|
1413
|
+
warn(` ${project.id} has no legacy config.toml at ${legacyToml}; skipping migration.`);
|
|
1414
|
+
warn(` Re-run setup to regenerate config.toml, then 'quadwork start' will retry migration.`);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (!tomlReady) {
|
|
1419
|
+
warn(` ${project.id} migration aborted: no config.toml at ${targetToml}.`);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// 3. Persist agentchattr_dir on the project entry — only after the
|
|
1424
|
+
// clone has run.py + venv + config.toml all in place.
|
|
1425
|
+
if (project.agentchattr_dir !== perProjectDir) {
|
|
1426
|
+
project.agentchattr_dir = perProjectDir;
|
|
1427
|
+
mutated = true;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (mutated) {
|
|
1432
|
+
try { writeConfig(config); ok("Updated config.json with per-project agentchattr_dir entries"); }
|
|
1433
|
+
catch (e) { warn(`Failed to write config.json: ${e.message}`); }
|
|
1434
|
+
}
|
|
1435
|
+
log(" Legacy ~/.quadwork/agentchattr/ left in place; remove via cleanup script (#189).");
|
|
1436
|
+
return true;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1179
1439
|
function cmdStart() {
|
|
1180
1440
|
console.log("\n QuadWork Start\n");
|
|
1181
1441
|
|
|
@@ -1184,6 +1444,11 @@ function cmdStart() {
|
|
|
1184
1444
|
warn("No projects configured yet. Create one at the setup page.");
|
|
1185
1445
|
}
|
|
1186
1446
|
|
|
1447
|
+
// Phase 3 / #181: migrate legacy single-install projects to their
|
|
1448
|
+
// own per-project clones before any AgentChattr spawn happens.
|
|
1449
|
+
// Idempotent — a no-op once every project already has a working clone.
|
|
1450
|
+
migrateLegacyProjects(config);
|
|
1451
|
+
|
|
1187
1452
|
const quadworkDir = path.join(__dirname, "..");
|
|
1188
1453
|
const port = config.port || 8400;
|
|
1189
1454
|
|
|
@@ -1201,27 +1466,38 @@ function cmdStart() {
|
|
|
1201
1466
|
process.exit(1);
|
|
1202
1467
|
}
|
|
1203
1468
|
|
|
1204
|
-
// Start AgentChattr for each project
|
|
1469
|
+
// Start AgentChattr for each project from its own per-project clone.
|
|
1470
|
+
// Phase 2E / #181: each project entry now has agentchattr_dir, set by
|
|
1471
|
+
// the wizards in #184/#185. Resolve per-project so two projects with
|
|
1472
|
+
// their own clones (and their own ports) can run side by side without
|
|
1473
|
+
// sharing a single global install. Falls back to the legacy global
|
|
1474
|
+
// install dir for v1 entries that have not been migrated yet (#188).
|
|
1205
1475
|
const acPids = [];
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1476
|
+
const legacyAcDir = findAgentChattr(config.agentchattr_dir);
|
|
1477
|
+
for (const project of config.projects) {
|
|
1478
|
+
if (!project.working_dir) continue;
|
|
1479
|
+
const projectAcDir = findAgentChattr(project.agentchattr_dir) || legacyAcDir;
|
|
1480
|
+
if (!projectAcDir) continue;
|
|
1481
|
+
// config.toml lives at the clone ROOT for new projects; legacy v1
|
|
1482
|
+
// setups still keep it under <working_dir>/agentchattr/config.toml.
|
|
1483
|
+
const perProjectToml = path.join(projectAcDir, "config.toml");
|
|
1484
|
+
const legacyToml = path.join(project.working_dir, "agentchattr", "config.toml");
|
|
1485
|
+
const configToml = fs.existsSync(perProjectToml)
|
|
1486
|
+
? perProjectToml
|
|
1487
|
+
: (fs.existsSync(legacyToml) ? legacyToml : null);
|
|
1488
|
+
if (!configToml) continue;
|
|
1489
|
+
const acSpawn = chattrSpawnArgs(projectAcDir, ["--config", configToml]);
|
|
1490
|
+
if (!acSpawn) continue;
|
|
1491
|
+
const acProc = spawn(acSpawn.command, acSpawn.spawnArgs, {
|
|
1492
|
+
cwd: acSpawn.cwd,
|
|
1493
|
+
stdio: "ignore",
|
|
1494
|
+
detached: true,
|
|
1495
|
+
});
|
|
1496
|
+
acProc.on("error", () => {});
|
|
1497
|
+
acProc.unref();
|
|
1498
|
+
if (acProc.pid) {
|
|
1499
|
+
ok(`AgentChattr started for ${project.id} from ${projectAcDir} (PID: ${acProc.pid})`);
|
|
1500
|
+
acPids.push(acProc.pid);
|
|
1225
1501
|
}
|
|
1226
1502
|
}
|
|
1227
1503
|
|
|
@@ -1321,7 +1597,11 @@ async function cmdAddProject() {
|
|
|
1321
1597
|
|
|
1322
1598
|
writeQuadWorkConfig(setup);
|
|
1323
1599
|
|
|
1324
|
-
|
|
1600
|
+
// Phase 2C / #181: config.toml lives at the per-project clone ROOT
|
|
1601
|
+
// because AgentChattr's run.py loads ROOT/config.toml and ignores
|
|
1602
|
+
// --config. Must match the install path used inside
|
|
1603
|
+
// writeAgentChattrConfig(): CONFIG_DIR/{projectName}/agentchattr.
|
|
1604
|
+
const configTomlPath = path.join(CONFIG_DIR, setup.projectName, "agentchattr", "config.toml");
|
|
1325
1605
|
writeAgentChattrConfig(setup, configTomlPath);
|
|
1326
1606
|
|
|
1327
1607
|
header("Project Added");
|
|
@@ -1338,6 +1618,112 @@ async function cmdAddProject() {
|
|
|
1338
1618
|
}
|
|
1339
1619
|
}
|
|
1340
1620
|
|
|
1621
|
+
// ─── Cleanup Command (#181 sub-H) ───────────────────────────────────────────
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Reclaim disk space taken by per-project AgentChattr clones (~77 MB each)
|
|
1625
|
+
* or by the legacy shared install left behind after migration (#188).
|
|
1626
|
+
*
|
|
1627
|
+
* Usage:
|
|
1628
|
+
* npx quadwork cleanup --project <id>
|
|
1629
|
+
* Removes ~/.quadwork/{id}/ and the matching entry from config.json.
|
|
1630
|
+
* Leaves the user's worktrees and source repos completely alone.
|
|
1631
|
+
*
|
|
1632
|
+
* npx quadwork cleanup --legacy
|
|
1633
|
+
* Removes the legacy shared ~/.quadwork/agentchattr/ install. Refuses
|
|
1634
|
+
* to run unless every project in config.json already has its own
|
|
1635
|
+
* working per-project clone (so nothing falls back onto the legacy
|
|
1636
|
+
* install via #186's resolution ladder).
|
|
1637
|
+
*
|
|
1638
|
+
* Both modes prompt for confirmation before deleting.
|
|
1639
|
+
*/
|
|
1640
|
+
async function cmdCleanup() {
|
|
1641
|
+
const args = process.argv.slice(3);
|
|
1642
|
+
const projectFlagIdx = args.indexOf("--project");
|
|
1643
|
+
const projectId = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : null;
|
|
1644
|
+
const legacy = args.includes("--legacy");
|
|
1645
|
+
|
|
1646
|
+
if (!projectId && !legacy) {
|
|
1647
|
+
console.log(`
|
|
1648
|
+
Usage:
|
|
1649
|
+
npx quadwork cleanup --project <id> Remove a project's AgentChattr clone + config entry
|
|
1650
|
+
npx quadwork cleanup --legacy Remove the legacy ~/.quadwork/agentchattr/ install
|
|
1651
|
+
`);
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1656
|
+
try {
|
|
1657
|
+
const config = readConfig();
|
|
1658
|
+
|
|
1659
|
+
// --- Per-project cleanup ---
|
|
1660
|
+
if (projectId) {
|
|
1661
|
+
const idx = (config.projects || []).findIndex((p) => p.id === projectId);
|
|
1662
|
+
const projectDir = path.join(CONFIG_DIR, projectId);
|
|
1663
|
+
if (idx < 0 && !fs.existsSync(projectDir)) {
|
|
1664
|
+
warn(`No project '${projectId}' in config and no directory at ${projectDir}.`);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
header(`Cleanup: ${projectId}`);
|
|
1668
|
+
if (fs.existsSync(projectDir)) log(` Directory: ${projectDir}`);
|
|
1669
|
+
if (idx >= 0) log(` Config entry: ${projectId} (${config.projects[idx].repo || "no repo"})`);
|
|
1670
|
+
log(" Worktrees and source repos will NOT be touched.");
|
|
1671
|
+
const confirm = await askYN(rl, `Delete ${projectDir} and remove the config entry?`, false);
|
|
1672
|
+
if (!confirm) { warn("Aborted."); return; }
|
|
1673
|
+
|
|
1674
|
+
if (fs.existsSync(projectDir)) {
|
|
1675
|
+
try { fs.rmSync(projectDir, { recursive: true, force: true }); ok(`Removed ${projectDir}`); }
|
|
1676
|
+
catch (e) { fail(`Could not remove ${projectDir}: ${e.message}`); return; }
|
|
1677
|
+
}
|
|
1678
|
+
if (idx >= 0) {
|
|
1679
|
+
config.projects.splice(idx, 1);
|
|
1680
|
+
try { writeConfig(config); ok(`Updated ${CONFIG_PATH}`); }
|
|
1681
|
+
catch (e) { fail(`Could not write config: ${e.message}`); return; }
|
|
1682
|
+
}
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// --- Legacy cleanup ---
|
|
1687
|
+
if (legacy) {
|
|
1688
|
+
const legacyDir = path.join(CONFIG_DIR, "agentchattr");
|
|
1689
|
+
if (!fs.existsSync(legacyDir)) {
|
|
1690
|
+
warn(`No legacy install at ${legacyDir}.`);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
header("Cleanup: legacy ~/.quadwork/agentchattr/");
|
|
1694
|
+
|
|
1695
|
+
// Refuse if any project still depends on the legacy install — i.e.
|
|
1696
|
+
// any project without its own working per-project clone (run.py +
|
|
1697
|
+
// venv + config.toml at ROOT). Mirrors #186's resolution ladder.
|
|
1698
|
+
const stillDepends = [];
|
|
1699
|
+
for (const p of config.projects || []) {
|
|
1700
|
+
if (!p.id) continue;
|
|
1701
|
+
const dir = p.agentchattr_dir || path.join(CONFIG_DIR, p.id, "agentchattr");
|
|
1702
|
+
const ok = fs.existsSync(path.join(dir, "run.py")) &&
|
|
1703
|
+
fs.existsSync(path.join(dir, ".venv", "bin", "python")) &&
|
|
1704
|
+
fs.existsSync(path.join(dir, "config.toml"));
|
|
1705
|
+
if (!ok) stillDepends.push(p.id);
|
|
1706
|
+
}
|
|
1707
|
+
if (stillDepends.length > 0) {
|
|
1708
|
+
fail(`Refusing to remove legacy install — these projects still depend on it:`);
|
|
1709
|
+
for (const id of stillDepends) console.log(` - ${id}`);
|
|
1710
|
+
warn(`Run 'npx quadwork start' to migrate them (#188), then re-run cleanup --legacy.`);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
log(` Directory: ${legacyDir}`);
|
|
1715
|
+
log(" All projects already have their own per-project clones.");
|
|
1716
|
+
const confirm = await askYN(rl, `Delete ${legacyDir}?`, false);
|
|
1717
|
+
if (!confirm) { warn("Aborted."); return; }
|
|
1718
|
+
|
|
1719
|
+
try { fs.rmSync(legacyDir, { recursive: true, force: true }); ok(`Removed ${legacyDir}`); }
|
|
1720
|
+
catch (e) { fail(`Could not remove ${legacyDir}: ${e.message}`); return; }
|
|
1721
|
+
}
|
|
1722
|
+
} finally {
|
|
1723
|
+
rl.close();
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1341
1727
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1342
1728
|
|
|
1343
1729
|
const command = process.argv[2];
|
|
@@ -1355,6 +1741,9 @@ switch (command) {
|
|
|
1355
1741
|
case "add-project":
|
|
1356
1742
|
cmdAddProject();
|
|
1357
1743
|
break;
|
|
1744
|
+
case "cleanup":
|
|
1745
|
+
cmdCleanup();
|
|
1746
|
+
break;
|
|
1358
1747
|
default:
|
|
1359
1748
|
console.log(`
|
|
1360
1749
|
Usage: quadwork <command>
|
|
@@ -1364,6 +1753,7 @@ switch (command) {
|
|
|
1364
1753
|
start Start the QuadWork dashboard and backend
|
|
1365
1754
|
stop Stop all QuadWork processes
|
|
1366
1755
|
add-project Add a project via CLI (alternative to web UI /setup)
|
|
1756
|
+
cleanup Reclaim disk space (--project <id> or --legacy)
|
|
1367
1757
|
|
|
1368
1758
|
Workflow:
|
|
1369
1759
|
1. npx quadwork init — one-time global setup, opens dashboard
|
|
@@ -1374,6 +1764,8 @@ switch (command) {
|
|
|
1374
1764
|
npx quadwork init
|
|
1375
1765
|
npx quadwork start
|
|
1376
1766
|
npx quadwork stop
|
|
1767
|
+
npx quadwork cleanup --project my-project
|
|
1768
|
+
npx quadwork cleanup --legacy
|
|
1377
1769
|
`);
|
|
1378
1770
|
if (command) process.exit(1);
|
|
1379
1771
|
}
|