quadwork 1.19.2 → 2.0.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 +19 -35
- package/bin/quadwork.js +48 -1118
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +14 -14
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
- package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
- package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
- package/out/_next/static/chunks/{153f.fj8jlvle.js → 0_lyyn..t63bc.js} +1 -1
- package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
- package/out/_next/static/chunks/0py7102i226n5.js +1 -0
- package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
- package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
- package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
- package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
- package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
- package/out/_not-found/__next._full.txt +13 -13
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- 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 +13 -13
- package/out/app-shell/__next._full.txt +13 -13
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +8 -8
- 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 +13 -13
- package/out/index.html +1 -1
- package/out/index.txt +14 -14
- package/out/project/_/__next._full.txt +14 -14
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +8 -8
- 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/_/queue/__next._full.txt +14 -14
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +8 -8
- 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 +14 -14
- package/out/project/_.html +1 -1
- package/out/project/_.txt +14 -14
- package/out/settings/__next._full.txt +14 -14
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +8 -8
- 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 +14 -14
- package/out/setup/__next._full.txt +14 -14
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +8 -8
- 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 +14 -14
- package/package.json +4 -2
- package/server/ac-restore.js +128 -0
- package/server/bridges/discord.js +183 -0
- package/server/bridges/telegram.js +210 -0
- package/server/config.js +4 -60
- package/server/file-chat.js +318 -0
- package/server/index.js +173 -1286
- package/server/install-agentchattr.js +3 -284
- package/server/mcp-chat-shim.js +171 -0
- package/server/migrate-ac.js +158 -0
- package/server/pty-dispatcher.js +188 -0
- package/server/routes.js +149 -1397
- package/templates/CLAUDE.md +2 -2
- package/templates/OVERNIGHT-QUEUE.md +1 -1
- package/templates/seeds/butler.CLAUDE.md +30 -62
- package/templates/seeds/dev.AGENTS.md +10 -1
- package/templates/seeds/head.AGENTS.md +3 -3
- package/templates/seeds/re1.AGENTS.md +3 -3
- package/templates/seeds/re2.AGENTS.md +3 -3
- package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
- package/bridges/discord/discord_bridge.py +0 -666
- package/bridges/discord/requirements.txt +0 -2
- package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
- package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
- package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
- package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
- package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
- package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
- package/server/__tests__/rate-limit-handling.test.js +0 -168
- package/server/__tests__/scrub-secrets.test.js +0 -235
- package/server/__tests__/v1110-security-qa.test.js +0 -312
- package/server/agentchattr-registry.js +0 -188
- package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
- package/server/queue-watcher.js +0 -171
- package/server/queue-watcher.test.js +0 -64
- package/server/routes.batchProgress.test.js +0 -94
- package/server/routes.chatWsSend.test.js +0 -161
- package/server/routes.discordBridge.test.js +0 -80
- package/server/routes.parseActiveBatch.test.js +0 -88
- package/server/routes.telegramBridge.test.js +0 -241
- package/templates/config.toml +0 -72
- package/templates/wrapper.py +0 -70
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
package/server/queue-watcher.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-agent queue watcher (#393 / quadwork#251).
|
|
3
|
-
*
|
|
4
|
-
* AgentChattr does NOT push chat to agents. When the operator types
|
|
5
|
-
* `@head` in chat, AC writes a job line to `{data_dir}/{name}_queue.jsonl`
|
|
6
|
-
* and walks away. Something on the agent side has to poll that file and
|
|
7
|
-
* inject an `mcp read` prompt into the running CLI's PTY so the agent
|
|
8
|
-
* picks up the chat. Without that injection the agent never responds,
|
|
9
|
-
* even when registration and heartbeats work.
|
|
10
|
-
*
|
|
11
|
-
* Reference: /Users/cho/Projects/agentchattr/wrapper.py lines 438-541
|
|
12
|
-
* (`_queue_watcher`). Polling (not fs.watch) is intentional: matches
|
|
13
|
-
* wrapper.py's behavior and avoids the cross-platform fs.watch
|
|
14
|
-
* footguns.
|
|
15
|
-
*
|
|
16
|
-
* #342 / quadwork#342: the v1 prompt intentionally omitted the
|
|
17
|
-
* identity hints from wrapper.py lines 501-528, which broke
|
|
18
|
-
* Claude Code agent sessions. Claude's default self-concept is
|
|
19
|
-
* `@claude`, so a bare `mcp read #general - you were mentioned`
|
|
20
|
-
* causes chat_read(sender: "claude") and a filter on `@claude`
|
|
21
|
-
* mentions — both wrong when the agent is actually @dev /
|
|
22
|
-
* @reviewerN. Codex doesn't trip the same way because its init
|
|
23
|
-
* path already claims identity. The fix here is to scope the
|
|
24
|
-
* wrapper.py additions to identity only: the injected prompt
|
|
25
|
-
* now explicitly names the agent slug and tells the agent which
|
|
26
|
-
* sender to use on chat_read and which mentions to look for.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const fs = require("fs");
|
|
30
|
-
const path = require("path");
|
|
31
|
-
|
|
32
|
-
const POLL_INTERVAL_MS = 1000;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Pure helper: build the injected prompt text for a given agent
|
|
36
|
-
* slug + trigger shape. Exported so it can be unit-tested without
|
|
37
|
-
* a PTY or a filesystem queue. Priority matches the tick() call
|
|
38
|
-
* site below: customPrompt > jobId > channel.
|
|
39
|
-
*
|
|
40
|
-
* agentName is expected to be the registered agent slug such as
|
|
41
|
-
* `dev`, `head`, `re1`, `re2`. The helper does not
|
|
42
|
-
* validate — upstream already controls who may register.
|
|
43
|
-
*/
|
|
44
|
-
function buildInjectionPrompt(agentName, { channel, jobId, customPrompt, attachments } = {}) {
|
|
45
|
-
if (customPrompt && typeof customPrompt === "string" && customPrompt.trim()) {
|
|
46
|
-
// Operator-supplied prompts already control the identity
|
|
47
|
-
// wording; leave them alone.
|
|
48
|
-
return customPrompt.trim();
|
|
49
|
-
}
|
|
50
|
-
let prompt;
|
|
51
|
-
if (jobId) {
|
|
52
|
-
prompt =
|
|
53
|
-
`You are @${agentName} in this AgentChattr instance. ` +
|
|
54
|
-
`mcp read job_id=${jobId} with sender: "${agentName}" — ` +
|
|
55
|
-
`you (@${agentName}) were mentioned in a job thread, take appropriate action.`;
|
|
56
|
-
} else {
|
|
57
|
-
const ch = channel || "general";
|
|
58
|
-
prompt =
|
|
59
|
-
`You are @${agentName} in this AgentChattr instance. ` +
|
|
60
|
-
`mcp read #${ch} with sender: "${agentName}" — ` +
|
|
61
|
-
`look for @${agentName} mentions (NOT @claude). ` +
|
|
62
|
-
`You were mentioned, take appropriate action.`;
|
|
63
|
-
}
|
|
64
|
-
// #466: include attachment paths so image-capable agents can read them
|
|
65
|
-
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
66
|
-
for (const att of attachments) {
|
|
67
|
-
if (att && att.path) {
|
|
68
|
-
prompt += ` Image attached: ${att.path} — use Read tool to view it.`;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return prompt;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Start polling `{dataDir}/{agentName}_queue.jsonl`. When non-empty,
|
|
77
|
-
* read all lines, truncate the file (atomic-ish claim — same race the
|
|
78
|
-
* Python wrapper accepts), parse each JSON line, build a single
|
|
79
|
-
* injected prompt, and write it into the supplied PTY terminal.
|
|
80
|
-
*
|
|
81
|
-
* Returns an opaque interval handle. Pass it to stopQueueWatcher to
|
|
82
|
-
* cancel; safe to call with null.
|
|
83
|
-
*/
|
|
84
|
-
function startQueueWatcher(dataDir, agentName, ptyTerm) {
|
|
85
|
-
if (!dataDir || !agentName || !ptyTerm) return null;
|
|
86
|
-
const queueFile = path.join(dataDir, `${agentName}_queue.jsonl`);
|
|
87
|
-
|
|
88
|
-
const tick = () => {
|
|
89
|
-
try {
|
|
90
|
-
if (!fs.existsSync(queueFile)) return;
|
|
91
|
-
const stat = fs.statSync(queueFile);
|
|
92
|
-
if (stat.size === 0) return;
|
|
93
|
-
|
|
94
|
-
const content = fs.readFileSync(queueFile, "utf-8");
|
|
95
|
-
// Atomic claim: truncate immediately so the next AC write lands
|
|
96
|
-
// in an empty file and we don't double-process the same job on
|
|
97
|
-
// the next tick. There's a small race if AC writes between the
|
|
98
|
-
// read and the truncate; wrapper.py accepts the same race.
|
|
99
|
-
fs.writeFileSync(queueFile, "");
|
|
100
|
-
|
|
101
|
-
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
102
|
-
if (lines.length === 0) return;
|
|
103
|
-
|
|
104
|
-
let channel = "general";
|
|
105
|
-
let customPrompt = "";
|
|
106
|
-
let jobId = null;
|
|
107
|
-
let hasTrigger = false;
|
|
108
|
-
for (const line of lines) {
|
|
109
|
-
let data;
|
|
110
|
-
try {
|
|
111
|
-
data = JSON.parse(line);
|
|
112
|
-
} catch {
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
hasTrigger = true;
|
|
116
|
-
if (data && typeof data === "object") {
|
|
117
|
-
if (typeof data.channel === "string") channel = data.channel;
|
|
118
|
-
// AgentChattr serializes job_id as an integer (agents.py
|
|
119
|
-
// defines `job_id: int | None`), so accept both numbers and
|
|
120
|
-
// strings here. Without this, job-thread triggers fall back
|
|
121
|
-
// to the channel prompt and the agent reads the wrong
|
|
122
|
-
// conversation. Cast to string for the prompt template.
|
|
123
|
-
if (typeof data.job_id === "number" || typeof data.job_id === "string") {
|
|
124
|
-
jobId = String(data.job_id);
|
|
125
|
-
}
|
|
126
|
-
if (typeof data.prompt === "string" && data.prompt.trim()) {
|
|
127
|
-
customPrompt = data.prompt.trim();
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (!hasTrigger) return;
|
|
132
|
-
|
|
133
|
-
const prompt = buildInjectionPrompt(agentName, { channel, jobId, customPrompt });
|
|
134
|
-
|
|
135
|
-
// Flatten newlines: multi-line writes trigger paste detection in
|
|
136
|
-
// Claude Code (shows "[Pasted text +N]") and can break injection
|
|
137
|
-
// of long prompts. Mirrors wrapper.py:532.
|
|
138
|
-
const flat = prompt.replace(/\n/g, " ");
|
|
139
|
-
// Inject text and Enter as SEPARATE writes with a delay between.
|
|
140
|
-
// Codex's TUI does not submit when text + "\r" arrive in one chunk —
|
|
141
|
-
// it needs the text to render, then a separate Enter keystroke.
|
|
142
|
-
// Claude Code accepts either form. Mirrors wrapper_unix.py inject():
|
|
143
|
-
// tmux send-keys -l <text> ; sleep ; tmux send-keys Enter.
|
|
144
|
-
// Delay scales with prompt length so longer prompts get more time
|
|
145
|
-
// to render before submit.
|
|
146
|
-
ptyTerm.write(flat);
|
|
147
|
-
const submitDelayMs = Math.max(300, flat.length);
|
|
148
|
-
setTimeout(() => {
|
|
149
|
-
try { ptyTerm.write("\r"); } catch { /* swallow */ }
|
|
150
|
-
}, submitDelayMs);
|
|
151
|
-
} catch {
|
|
152
|
-
// Swallow — next tick will retry. Logging here would spam the
|
|
153
|
-
// server output once per second on a permission error.
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
return setInterval(tick, POLL_INTERVAL_MS);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Stop a watcher started by startQueueWatcher. Safe to call with null.
|
|
162
|
-
*/
|
|
163
|
-
function stopQueueWatcher(handle) {
|
|
164
|
-
if (handle) clearInterval(handle);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
module.exports = {
|
|
168
|
-
startQueueWatcher,
|
|
169
|
-
stopQueueWatcher,
|
|
170
|
-
buildInjectionPrompt,
|
|
171
|
-
};
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// #342 / quadwork#342: unit test for the identity-aware prompt
|
|
2
|
-
// builder. No test runner is wired up in this repo, so this file
|
|
3
|
-
// is a plain node:assert script — run it with `node server/queue-watcher.test.js`
|
|
4
|
-
// and it exits non-zero on any failure.
|
|
5
|
-
|
|
6
|
-
const assert = require("node:assert/strict");
|
|
7
|
-
const { buildInjectionPrompt } = require("./queue-watcher");
|
|
8
|
-
|
|
9
|
-
const DEFAULT_AGENT_SLUGS = ["dev", "head", "re1", "re2"];
|
|
10
|
-
|
|
11
|
-
// 1) Channel prompt — each of the 4 default slugs must:
|
|
12
|
-
// - name the agent with @<slug>
|
|
13
|
-
// - pass sender: "<slug>" explicitly
|
|
14
|
-
// - tell the agent to look for @<slug> mentions (NOT @claude)
|
|
15
|
-
for (const slug of DEFAULT_AGENT_SLUGS) {
|
|
16
|
-
const p = buildInjectionPrompt(slug, { channel: "general" });
|
|
17
|
-
assert.match(p, new RegExp(`You are @${slug} `), `channel: names @${slug}`);
|
|
18
|
-
assert.match(p, new RegExp(`sender: "${slug}"`), `channel: sender string for ${slug}`);
|
|
19
|
-
assert.match(p, new RegExp(`@${slug} mentions`), `channel: @${slug} mention filter`);
|
|
20
|
-
assert.match(p, /NOT @claude/, `channel: explicit NOT @claude guard for ${slug}`);
|
|
21
|
-
assert.match(p, /#general/, `channel: channel name for ${slug}`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 2) Channel defaults to "general" when not provided.
|
|
25
|
-
{
|
|
26
|
-
const p = buildInjectionPrompt("dev", {});
|
|
27
|
-
assert.match(p, /#general/, "channel defaults to general");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// 3) Non-default channel is passed through.
|
|
31
|
-
{
|
|
32
|
-
const p = buildInjectionPrompt("dev", { channel: "batch-33" });
|
|
33
|
-
assert.match(p, /#batch-33/, "custom channel is used");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// 4) Job-thread prompt — each slug must:
|
|
37
|
-
// - name the agent with @<slug>
|
|
38
|
-
// - reference the job_id
|
|
39
|
-
// - pass sender: "<slug>" explicitly
|
|
40
|
-
for (const slug of DEFAULT_AGENT_SLUGS) {
|
|
41
|
-
const p = buildInjectionPrompt(slug, { jobId: "42" });
|
|
42
|
-
assert.match(p, new RegExp(`You are @${slug} `), `job: names @${slug}`);
|
|
43
|
-
assert.match(p, /job_id=42/, `job: job_id for ${slug}`);
|
|
44
|
-
assert.match(p, new RegExp(`sender: "${slug}"`), `job: sender string for ${slug}`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 5) customPrompt wins over channel and jobId and is returned as-is.
|
|
48
|
-
{
|
|
49
|
-
const p = buildInjectionPrompt("dev", {
|
|
50
|
-
channel: "general",
|
|
51
|
-
jobId: "99",
|
|
52
|
-
customPrompt: " do the thing ",
|
|
53
|
-
});
|
|
54
|
-
assert.equal(p, "do the thing", "customPrompt overrides + trims");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 6) Blank customPrompt is ignored (falls through to channel/job path).
|
|
58
|
-
{
|
|
59
|
-
const p = buildInjectionPrompt("re2", { customPrompt: " ", channel: "general" });
|
|
60
|
-
assert.match(p, /You are @re2 /);
|
|
61
|
-
assert.match(p, /#general/);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
console.log(`queue-watcher.test.js: all assertions passed (${DEFAULT_AGENT_SLUGS.length * 2 + 4} cases)`);
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// #350 / quadwork#350: batch-progress no-linked-PR row builder +
|
|
2
|
-
// summarizer tests. Plain node:assert script — run with
|
|
3
|
-
// `node server/routes.batchProgress.test.js`.
|
|
4
|
-
|
|
5
|
-
const assert = require("node:assert/strict");
|
|
6
|
-
const { buildNoPrRow, summarizeItems } = require("./routes");
|
|
7
|
-
|
|
8
|
-
// 1) #350 regression fixture: CLOSED issue with no linked PR
|
|
9
|
-
// must render as 100% complete, not 0% queued.
|
|
10
|
-
{
|
|
11
|
-
const issue = {
|
|
12
|
-
number: 336,
|
|
13
|
-
title: "superseded by #338",
|
|
14
|
-
state: "CLOSED",
|
|
15
|
-
url: "https://github.com/realproject7/quadwork/issues/336",
|
|
16
|
-
};
|
|
17
|
-
const row = buildNoPrRow(issue);
|
|
18
|
-
assert.equal(row.status, "closed", "CLOSED with no PR → status=closed");
|
|
19
|
-
assert.equal(row.progress, 100, "CLOSED with no PR → 100%");
|
|
20
|
-
assert.match(row.label, /Closed.*✓/, "label has Closed and ✓ marker");
|
|
21
|
-
assert.equal(row.issue_number, 336);
|
|
22
|
-
assert.equal(row.url, issue.url);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// 2) OPEN issue with no linked PR still renders as queued.
|
|
26
|
-
{
|
|
27
|
-
const issue = {
|
|
28
|
-
number: 400,
|
|
29
|
-
title: "still open",
|
|
30
|
-
state: "OPEN",
|
|
31
|
-
url: "https://github.com/realproject7/quadwork/issues/400",
|
|
32
|
-
};
|
|
33
|
-
const row = buildNoPrRow(issue);
|
|
34
|
-
assert.equal(row.status, "queued", "OPEN with no PR → queued");
|
|
35
|
-
assert.equal(row.progress, 0);
|
|
36
|
-
assert.equal(row.label, "Issue · queued");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 3) summarizeItems with a mix of merged and closed-without-PR:
|
|
40
|
-
// should count both toward the complete total, label "complete"
|
|
41
|
-
// when closed > 0.
|
|
42
|
-
{
|
|
43
|
-
const items = [
|
|
44
|
-
{ status: "merged" },
|
|
45
|
-
{ status: "merged" },
|
|
46
|
-
{ status: "merged" },
|
|
47
|
-
{ status: "merged" },
|
|
48
|
-
{ status: "merged" },
|
|
49
|
-
{ status: "closed" },
|
|
50
|
-
{ status: "closed" },
|
|
51
|
-
];
|
|
52
|
-
const out = summarizeItems(items);
|
|
53
|
-
assert.equal(out, "7/7 complete", "mixed merged+closed → X/N complete");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 4) summarizeItems with only merged items keeps the classic
|
|
57
|
-
// "X/N merged" wording (no behavior change for PR-only batches).
|
|
58
|
-
{
|
|
59
|
-
const items = [
|
|
60
|
-
{ status: "merged" },
|
|
61
|
-
{ status: "merged" },
|
|
62
|
-
{ status: "merged" },
|
|
63
|
-
];
|
|
64
|
-
assert.equal(summarizeItems(items), "3/3 merged");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 5) summarizeItems with a queued + closed mix: done count is
|
|
68
|
-
// closed only, queued surfaces in the detail tail.
|
|
69
|
-
{
|
|
70
|
-
const items = [
|
|
71
|
-
{ status: "closed" },
|
|
72
|
-
{ status: "queued" },
|
|
73
|
-
{ status: "queued" },
|
|
74
|
-
];
|
|
75
|
-
assert.equal(summarizeItems(items), "1/3 complete · 2 queued");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 6) summarizeItems with in-flight PR states still tallies them
|
|
79
|
-
// in the detail tail and keeps the done count at merged-only.
|
|
80
|
-
{
|
|
81
|
-
const items = [
|
|
82
|
-
{ status: "merged" },
|
|
83
|
-
{ status: "ready" },
|
|
84
|
-
{ status: "approved1" },
|
|
85
|
-
{ status: "in_review" },
|
|
86
|
-
{ status: "queued" },
|
|
87
|
-
];
|
|
88
|
-
assert.equal(
|
|
89
|
-
summarizeItems(items),
|
|
90
|
-
"1/5 merged · 1 ready to merge · 1 needs 2nd approval · 1 in review · 1 queued",
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
console.log("routes.batchProgress.test.js: all assertions passed (6 cases)");
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
// #236 / quadwork#236: sendViaWebSocket ack/body/error regression.
|
|
2
|
-
// Stands up a minimal fake AgentChattr ws server that mirrors app.py's
|
|
3
|
-
// `type:"message"` handler behavior (accept the frame, assign an id +
|
|
4
|
-
// timestamp, broadcast `{type:"message", data: msg}` back to the
|
|
5
|
-
// sender) and verifies:
|
|
6
|
-
//
|
|
7
|
-
// 1. Successful send resolves with {ok:true, message:{id,…}} — the
|
|
8
|
-
// echoed broadcast frame, not a fake {ok:true}.
|
|
9
|
-
// 2. Attachments survive the round trip.
|
|
10
|
-
// 3. History replay on connect does NOT satisfy the ack (the echo
|
|
11
|
-
// must come AFTER the send, not before).
|
|
12
|
-
// 4. A premature close without an echo rejects with an error (the
|
|
13
|
-
// old fire-and-forget path silently resolved).
|
|
14
|
-
// 5. Close code 4003 rejects with err.code === "EAGENTCHATTR_401"
|
|
15
|
-
// so the /api/chat handler can surface a proper 401.
|
|
16
|
-
//
|
|
17
|
-
// Run with: node server/routes.chatWsSend.test.js
|
|
18
|
-
|
|
19
|
-
const assert = require("node:assert/strict");
|
|
20
|
-
const http = require("node:http");
|
|
21
|
-
const { WebSocketServer } = require("ws");
|
|
22
|
-
const { sendViaWebSocket } = require("./routes");
|
|
23
|
-
|
|
24
|
-
function startFakeAc({ historyBeforeAck = [], rejectWithCode = null, dropBeforeAck = false } = {}) {
|
|
25
|
-
const server = http.createServer();
|
|
26
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
27
|
-
server.on("upgrade", (req, socket, head) => {
|
|
28
|
-
if (rejectWithCode === 4003) {
|
|
29
|
-
// Accept the upgrade, then immediately close with 4003 the way
|
|
30
|
-
// AC's /ws handler does on invalid token.
|
|
31
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
32
|
-
ws.close(4003, "forbidden: invalid session token");
|
|
33
|
-
});
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
37
|
-
// Simulate history replay on connect — these must NOT satisfy
|
|
38
|
-
// the ack, even if they match (sender,text,channel).
|
|
39
|
-
let historyMaxId = 0;
|
|
40
|
-
for (const msg of historyBeforeAck) {
|
|
41
|
-
ws.send(JSON.stringify({ type: "message", data: msg }));
|
|
42
|
-
if (typeof msg.id === "number" && msg.id > historyMaxId) historyMaxId = msg.id;
|
|
43
|
-
}
|
|
44
|
-
// Mirror AC: emit one `type:"status"` frame after history so the
|
|
45
|
-
// client knows the replay is done. See agentchattr/app.py
|
|
46
|
-
// `broadcast_status()` call in the /ws handler (line ~1082).
|
|
47
|
-
ws.send(JSON.stringify({ type: "status", data: { ready: true } }));
|
|
48
|
-
let nextId = Math.max(9000, historyMaxId + 1);
|
|
49
|
-
ws.on("message", (raw) => {
|
|
50
|
-
const frame = JSON.parse(raw.toString());
|
|
51
|
-
if (frame.type !== "message") return;
|
|
52
|
-
if (dropBeforeAck) {
|
|
53
|
-
ws.close();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
// Mirror store.add: assign id + timestamp, rebroadcast.
|
|
57
|
-
const echoed = {
|
|
58
|
-
id: nextId++,
|
|
59
|
-
sender: frame.sender,
|
|
60
|
-
text: frame.text,
|
|
61
|
-
channel: frame.channel || "general",
|
|
62
|
-
attachments: frame.attachments || [],
|
|
63
|
-
reply_to: frame.reply_to ?? null,
|
|
64
|
-
timestamp: Date.now() / 1000,
|
|
65
|
-
};
|
|
66
|
-
ws.send(JSON.stringify({ type: "message", data: echoed }));
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
return new Promise((resolve) => {
|
|
71
|
-
server.listen(0, "127.0.0.1", () => {
|
|
72
|
-
const { port } = server.address();
|
|
73
|
-
resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(() => r())) });
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
(async () => {
|
|
79
|
-
// 1 + 2) Success path: echo carries server id + attachments preserved.
|
|
80
|
-
{
|
|
81
|
-
const ac = await startFakeAc();
|
|
82
|
-
const result = await sendViaWebSocket(ac.url, "fake-token", {
|
|
83
|
-
text: "hello from test",
|
|
84
|
-
sender: "user",
|
|
85
|
-
channel: "general",
|
|
86
|
-
attachments: [{ url: "/uploads/x.png", name: "x.png" }],
|
|
87
|
-
});
|
|
88
|
-
assert.equal(result.ok, true);
|
|
89
|
-
assert.ok(result.message, "expected echoed message object");
|
|
90
|
-
assert.equal(typeof result.message.id, "number");
|
|
91
|
-
assert.ok(result.message.id >= 9000);
|
|
92
|
-
assert.equal(result.message.text, "hello from test");
|
|
93
|
-
assert.equal(result.message.sender, "user");
|
|
94
|
-
assert.equal(result.message.attachments.length, 1);
|
|
95
|
-
assert.equal(result.message.attachments[0].name, "x.png");
|
|
96
|
-
await ac.close();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 3) Stale history replay must NOT satisfy the ack — even when the
|
|
100
|
-
// history contains a message that is IDENTICAL (sender, text,
|
|
101
|
-
// channel, reply_to) and has a recent timestamp (e.g. a retry of
|
|
102
|
-
// the same message sent <1s ago). Prior heuristic matcher was
|
|
103
|
-
// vulnerable to this; the fix uses the (1) status-frame history
|
|
104
|
-
// boundary and (2) strictly-greater-id correlation baseline so
|
|
105
|
-
// the historical echo is definitionally rejected. Reviewer1
|
|
106
|
-
// flagged this race on PR #382 round 1.
|
|
107
|
-
{
|
|
108
|
-
const stale = {
|
|
109
|
-
id: 42, sender: "user", text: "same words",
|
|
110
|
-
channel: "general", attachments: [], reply_to: null,
|
|
111
|
-
timestamp: Date.now() / 1000, // RIGHT NOW — old heuristic would accept this
|
|
112
|
-
};
|
|
113
|
-
const ac = await startFakeAc({ historyBeforeAck: [stale] });
|
|
114
|
-
const result = await sendViaWebSocket(ac.url, "fake-token", {
|
|
115
|
-
text: "same words",
|
|
116
|
-
sender: "user",
|
|
117
|
-
channel: "general",
|
|
118
|
-
attachments: [],
|
|
119
|
-
});
|
|
120
|
-
assert.ok(result.message.id > 42, `expected live echo id > 42, got ${result.message.id}`);
|
|
121
|
-
await ac.close();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 4) Premature close without ack rejects.
|
|
125
|
-
{
|
|
126
|
-
const ac = await startFakeAc({ dropBeforeAck: true });
|
|
127
|
-
let threw = false;
|
|
128
|
-
try {
|
|
129
|
-
await sendViaWebSocket(ac.url, "fake-token", {
|
|
130
|
-
text: "will drop", sender: "user", channel: "general", attachments: [],
|
|
131
|
-
});
|
|
132
|
-
} catch (err) {
|
|
133
|
-
threw = true;
|
|
134
|
-
assert.match(err.message, /closed before ack|websocket/);
|
|
135
|
-
assert.notEqual(err.code, "EAGENTCHATTR_401");
|
|
136
|
-
}
|
|
137
|
-
assert.equal(threw, true, "expected sendViaWebSocket to reject on premature close");
|
|
138
|
-
await ac.close();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 5) 4003 (bad token) rejects with EAGENTCHATTR_401.
|
|
142
|
-
{
|
|
143
|
-
const ac = await startFakeAc({ rejectWithCode: 4003 });
|
|
144
|
-
let threw = false;
|
|
145
|
-
try {
|
|
146
|
-
await sendViaWebSocket(ac.url, "bad-token", {
|
|
147
|
-
text: "denied", sender: "user", channel: "general", attachments: [],
|
|
148
|
-
});
|
|
149
|
-
} catch (err) {
|
|
150
|
-
threw = true;
|
|
151
|
-
assert.equal(err.code, "EAGENTCHATTR_401");
|
|
152
|
-
}
|
|
153
|
-
assert.equal(threw, true, "expected sendViaWebSocket to reject with EAGENTCHATTR_401");
|
|
154
|
-
await ac.close();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
console.log("routes.chatWsSend.test.js: all assertions passed (5 cases)");
|
|
158
|
-
})().catch((err) => {
|
|
159
|
-
console.error(err);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// #399: Discord bridge integration tests. Plain node:assert script —
|
|
2
|
-
// run with `node server/routes.discordBridge.test.js`.
|
|
3
|
-
|
|
4
|
-
const assert = require("node:assert/strict");
|
|
5
|
-
const fs = require("node:fs");
|
|
6
|
-
const os = require("node:os");
|
|
7
|
-
const path = require("node:path");
|
|
8
|
-
const {
|
|
9
|
-
checkDiscordBridgePythonDeps,
|
|
10
|
-
buildDiscordBridgeToml,
|
|
11
|
-
patchAgentchattrConfigForDiscordBridge,
|
|
12
|
-
buildDiscordBridgeSpawnEnv,
|
|
13
|
-
} = require("./routes");
|
|
14
|
-
|
|
15
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "qw-discord-bridge-"));
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
// 1) checkDiscordBridgePythonDeps: broken interpreter returns
|
|
19
|
-
// { ok: false, error } without throwing.
|
|
20
|
-
const broken = checkDiscordBridgePythonDeps(path.join(tmp, "nope", "python3"));
|
|
21
|
-
assert.equal(broken.ok, false);
|
|
22
|
-
assert.ok(broken.error && broken.error.length > 0);
|
|
23
|
-
|
|
24
|
-
// 2) buildDiscordBridgeToml writes agentchattr_url inside [discord].
|
|
25
|
-
const toml = buildDiscordBridgeToml({
|
|
26
|
-
bot_token: "MTIz.abc.def",
|
|
27
|
-
channel_id: "123456789",
|
|
28
|
-
agentchattr_url: "http://127.0.0.1:8301",
|
|
29
|
-
}, "testproject");
|
|
30
|
-
assert.match(toml, /^\[discord\]/);
|
|
31
|
-
assert.match(toml, /bot_token = "MTIz\.abc\.def"/);
|
|
32
|
-
assert.match(toml, /channel_id = "123456789"/);
|
|
33
|
-
assert.match(toml, /agentchattr_url = "http:\/\/127\.0\.0\.1:8301"/);
|
|
34
|
-
// Per-project cursor file
|
|
35
|
-
assert.match(toml, /cursor_file = ".*dc-bridge-cursor-testproject\.json"/);
|
|
36
|
-
// Must NOT emit a separate [agentchattr] section
|
|
37
|
-
assert.equal(toml.includes("\n[agentchattr]\n"), false);
|
|
38
|
-
|
|
39
|
-
// 3) patchAgentchattrConfigForDiscordBridge is idempotent.
|
|
40
|
-
const baseConfig =
|
|
41
|
-
"[agents.head]\nlabel = \"Head\"\n\n[agents.dev]\nlabel = \"Dev\"\n";
|
|
42
|
-
const first = patchAgentchattrConfigForDiscordBridge(baseConfig);
|
|
43
|
-
assert.equal(first.changed, true);
|
|
44
|
-
assert.match(first.text, /^\[agents\.dc\]$/m);
|
|
45
|
-
assert.match(first.text, /label = "Discord Bridge"/);
|
|
46
|
-
// Second run is a no-op
|
|
47
|
-
const second = patchAgentchattrConfigForDiscordBridge(first.text);
|
|
48
|
-
assert.equal(second.changed, false);
|
|
49
|
-
assert.equal(second.text, first.text);
|
|
50
|
-
// #439: old slug [agents.discord-bridge] is migrated to [agents.dc]
|
|
51
|
-
const handPatched =
|
|
52
|
-
baseConfig + "\n[agents.discord-bridge]\nlabel = \"Discord Bridge\"\n";
|
|
53
|
-
const third = patchAgentchattrConfigForDiscordBridge(handPatched);
|
|
54
|
-
assert.equal(third.changed, true);
|
|
55
|
-
assert.match(third.text, /^\[agents\.dc\]$/m);
|
|
56
|
-
|
|
57
|
-
// 4) buildDiscordBridgeSpawnEnv strips Discord-specific env vars.
|
|
58
|
-
const scrubbed = buildDiscordBridgeSpawnEnv({
|
|
59
|
-
PATH: "/usr/bin",
|
|
60
|
-
HOME: "/home/op",
|
|
61
|
-
DISCORD_BOT_TOKEN: "wrong-token",
|
|
62
|
-
DISCORD_CHANNEL_ID: "999",
|
|
63
|
-
AGENTCHATTR_URL: "http://127.0.0.1:9999",
|
|
64
|
-
});
|
|
65
|
-
assert.equal(scrubbed.DISCORD_BOT_TOKEN, undefined);
|
|
66
|
-
assert.equal(scrubbed.DISCORD_CHANNEL_ID, undefined);
|
|
67
|
-
assert.equal(scrubbed.AGENTCHATTR_URL, undefined);
|
|
68
|
-
// Non-discord keys pass through
|
|
69
|
-
assert.equal(scrubbed.PATH, "/usr/bin");
|
|
70
|
-
assert.equal(scrubbed.HOME, "/home/op");
|
|
71
|
-
|
|
72
|
-
// 5) Missing venv path check
|
|
73
|
-
const fixtureBridgeDir = fs.mkdtempSync(path.join(tmp, "bridge-no-venv-"));
|
|
74
|
-
const missingVenvPython = path.join(fixtureBridgeDir, ".venv", "bin", "python3");
|
|
75
|
-
assert.equal(fs.existsSync(missingVenvPython), false);
|
|
76
|
-
|
|
77
|
-
console.log("routes.discordBridge.test.js: all assertions passed (5 cases)");
|
|
78
|
-
} finally {
|
|
79
|
-
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
80
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
// #341 / quadwork#341: parseActiveBatch regex tests. Plain
|
|
2
|
-
// node:assert script — no test runner is wired up. Run with
|
|
3
|
-
// `node server/routes.parseActiveBatch.test.js`.
|
|
4
|
-
//
|
|
5
|
-
// parseActiveBatch is re-exported from server/routes.js for this
|
|
6
|
-
// test only; it has no production callers outside routes.js.
|
|
7
|
-
|
|
8
|
-
const assert = require("node:assert/strict");
|
|
9
|
-
const { parseActiveBatch } = require("./routes");
|
|
10
|
-
|
|
11
|
-
function wrap(body, batchLine = "**Batch:** 33") {
|
|
12
|
-
return `# Overnight Queue\n\n## Active Batch\n\n${batchLine}\n\n${body}\n\n## Backlog\n\n- #999 something else\n`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// 1) #341 regression: GFM checkbox items (space between `[` and `#`)
|
|
16
|
-
// must populate the list.
|
|
17
|
-
{
|
|
18
|
-
const text = wrap(
|
|
19
|
-
[
|
|
20
|
-
"- [ ] #338 — Remove home hero",
|
|
21
|
-
"- [ ] #337 — Stack SERVER",
|
|
22
|
-
"- [x] #332 — Commit port drafts",
|
|
23
|
-
"- [X] #334 — Snapshot stale check",
|
|
24
|
-
].join("\n"),
|
|
25
|
-
);
|
|
26
|
-
const { batchNumber, issueNumbers } = parseActiveBatch(text);
|
|
27
|
-
assert.equal(batchNumber, 33, "batch number parsed");
|
|
28
|
-
assert.deepEqual(issueNumbers, [338, 337, 332, 334], "checkbox items parsed in order");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// 2) Existing shapes keep working.
|
|
32
|
-
{
|
|
33
|
-
const text = wrap(
|
|
34
|
-
[
|
|
35
|
-
"- #295 sub-A heartbeat",
|
|
36
|
-
"* #296 sub-B",
|
|
37
|
-
"1. #297 sub-C",
|
|
38
|
-
"#298 sub-D",
|
|
39
|
-
"- [#299] sub-E",
|
|
40
|
-
"[#300] sub-F",
|
|
41
|
-
].join("\n"),
|
|
42
|
-
);
|
|
43
|
-
const { issueNumbers } = parseActiveBatch(text);
|
|
44
|
-
assert.deepEqual(issueNumbers, [295, 296, 297, 298, 299, 300], "legacy shapes still parsed");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 3) Prose references still rejected.
|
|
48
|
-
{
|
|
49
|
-
const text = wrap(
|
|
50
|
-
[
|
|
51
|
-
"- [ ] #400 real item",
|
|
52
|
-
"Tracking umbrella: #293",
|
|
53
|
-
"Assigned next after #294 merged.",
|
|
54
|
-
"See #295 for context.",
|
|
55
|
-
].join("\n"),
|
|
56
|
-
);
|
|
57
|
-
const { issueNumbers } = parseActiveBatch(text);
|
|
58
|
-
assert.deepEqual(issueNumbers, [400], "prose references rejected, only real item kept");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 4) De-dup: same issue number on multiple lines collapses.
|
|
62
|
-
{
|
|
63
|
-
const text = wrap(
|
|
64
|
-
[
|
|
65
|
-
"- [ ] #100 first mention",
|
|
66
|
-
"- [x] #100 second mention",
|
|
67
|
-
"- [ ] #101 another",
|
|
68
|
-
].join("\n"),
|
|
69
|
-
);
|
|
70
|
-
const { issueNumbers } = parseActiveBatch(text);
|
|
71
|
-
assert.deepEqual(issueNumbers, [100, 101], "de-dup keeps first occurrence");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 5) Items in Backlog section are NOT picked up.
|
|
75
|
-
{
|
|
76
|
-
const text = wrap("- [ ] #500 active item");
|
|
77
|
-
const { issueNumbers } = parseActiveBatch(text);
|
|
78
|
-
assert.deepEqual(issueNumbers, [500], "Backlog section not scanned");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 6) Empty / missing Active Batch returns empty.
|
|
82
|
-
{
|
|
83
|
-
const { batchNumber, issueNumbers } = parseActiveBatch("# no active batch here\n");
|
|
84
|
-
assert.equal(batchNumber, null);
|
|
85
|
-
assert.deepEqual(issueNumbers, []);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
console.log("routes.parseActiveBatch.test.js: all assertions passed (6 cases)");
|