@dyzsasd/dev-loop 0.22.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 +55 -0
- package/dist/agentops.js +551 -0
- package/dist/channel.js +226 -0
- package/dist/channelstore.js +269 -0
- package/dist/cli-tickets.js +131 -0
- package/dist/cli.js +77 -0
- package/dist/daemon-lifecycle.js +372 -0
- package/dist/daemon.js +805 -0
- package/dist/daemonviews.js +691 -0
- package/dist/db.js +385 -0
- package/dist/docstore.js +110 -0
- package/dist/doctor.js +230 -0
- package/dist/init-service.js +206 -0
- package/dist/labelstore.js +34 -0
- package/dist/linear.js +60 -0
- package/dist/mcp-merge.js +145 -0
- package/dist/mirrorstore.js +128 -0
- package/dist/release-version.js +39 -0
- package/dist/resolve-project.js +82 -0
- package/dist/seed.js +76 -0
- package/dist/server.js +134 -0
- package/dist/shim.js +146 -0
- package/dist/ticketwrite.js +147 -0
- package/dist/tooldefs.js +147 -0
- package/dist/topicstore.js +174 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# dev-loop
|
|
2
|
+
|
|
3
|
+
The standalone local **coordination hub** for the [dev-loop](https://github.com/dyzsasd/dev-loop)
|
|
4
|
+
agents — a **zero-build, zero-native-dependency** MCP system-of-record over `node:sqlite` with
|
|
5
|
+
**per-agent identity**, a localhost **web-UI daemon**, an opt-in agent **op-API + thin stdio shim**,
|
|
6
|
+
and a **CLI-portable** transport (Claude Code · Codex · opencode).
|
|
7
|
+
|
|
8
|
+
> One trusted host, localhost-only. Identity is **cooperative attribution** (not anti-spoof). Secrets
|
|
9
|
+
> live in env by **name** only. See the security envelope in
|
|
10
|
+
> [`docs/HUB-ARCHITECTURE.md`](https://github.com/dyzsasd/dev-loop/blob/main/docs/HUB-ARCHITECTURE.md).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g @dyzsasd/dev-loop # requires Node >= 23.6 (built-in node:sqlite + .ts type-stripping; zero build); installs the `dev-loop` + `dev-loop-hub` bins
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This puts two bins on `PATH`: **`dev-loop`** (the CLI) and **`dev-loop-hub`** (the MCP server entry).
|
|
19
|
+
|
|
20
|
+
## CLI
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
dev-loop serve run the stdio MCP server (the agent transport)
|
|
24
|
+
dev-loop shim the thin stdio MCP shim → the loopback daemon op-API
|
|
25
|
+
dev-loop daemon up|down|status per-project daemon lifecycle — idempotent, auto web UI
|
|
26
|
+
dev-loop init-service <key> <name> <PREFIX> turnkey-bootstrap a service-backend project
|
|
27
|
+
dev-loop mcp-merge <args> merge dev-loop-hub into a product .mcp.json (never clobbers)
|
|
28
|
+
dev-loop seed <key> <name> [PREFIX] seed a project + actors + labels
|
|
29
|
+
dev-loop doctor health-check the system-of-record (DOCTOR_OK)
|
|
30
|
+
dev-loop identity-check [--expect <actor>[/<project>]] the portability gate
|
|
31
|
+
dev-loop version | help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Identity & project (the env contract)
|
|
35
|
+
|
|
36
|
+
Every launcher sets, **per pane**, the identity the write is attributed to:
|
|
37
|
+
|
|
38
|
+
| Env var | Meaning |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `DEVLOOP_ACTOR` | the per-agent identity (`pm`/`qa`/`dev`/…) — the attribution |
|
|
41
|
+
| `DEVLOOP_PROJECT` | the pinned project key (or resolved from the cwd) |
|
|
42
|
+
| `DEVLOOP_HUB_DB` | the SQLite system-of-record (default `~/.dev-loop/hub.db`) |
|
|
43
|
+
|
|
44
|
+
Register it as an MCP server for your CLI — `{ "command": "dev-loop", "args": ["serve"] }` (or
|
|
45
|
+
`["shim"]` for the daemon transport). Per-CLI recipes + the identity gate:
|
|
46
|
+
[`docs/PORTABILITY.md`](https://github.com/dyzsasd/dev-loop/blob/main/docs/PORTABILITY.md).
|
|
47
|
+
|
|
48
|
+
## Docs
|
|
49
|
+
|
|
50
|
+
- [Architecture + safety envelope](https://github.com/dyzsasd/dev-loop/blob/main/docs/HUB-ARCHITECTURE.md)
|
|
51
|
+
- [Running the loop](https://github.com/dyzsasd/dev-loop/blob/main/docs/RUNNING.md) ·
|
|
52
|
+
[The daemon](https://github.com/dyzsasd/dev-loop/blob/main/docs/DAEMON.md) ·
|
|
53
|
+
[Portability (Codex / opencode)](https://github.com/dyzsasd/dev-loop/blob/main/docs/PORTABILITY.md)
|
|
54
|
+
|
|
55
|
+
MIT © Shuai
|
package/dist/agentops.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// dev-loop hub — the agent op-API ops as plain functions: the SINGLE definition of every ticket/read policy
|
|
2
|
+
// the hub exposes. BOTH transports dispatch through these: the DL-43 daemon agent op-API (/api/op/*) and —
|
|
3
|
+
// since DL-69 (the dispatch-sharing refactor) — the stdio MCP server (server.ts), whose 27 op-backed tool
|
|
4
|
+
// handlers are now thin call-throughs to agentOp() (server.ts's toMcp() maps {status,body}→MCP ok()/err()).
|
|
5
|
+
// So each policy — the read SELECTs, the save_issue/save_comment orchestration (the DL-24 per-transition
|
|
6
|
+
// assignTo + the DL-32 prod-promotion gate + the REPLACE-labels/APPEND-relatedTo merge), and the doc/topic/
|
|
7
|
+
// channel/mirror/label families (which also reuse the shared ticketwrite/docstore/topicstore/channelstore/
|
|
8
|
+
// mirrorstore/labelstore) — has EXACTLY ONE definition. The old "edit both files" drift tripwire is RETIRED:
|
|
9
|
+
// a change to any policy now lands in ONE place, and the differential-parity suite (test/shim.ts +
|
|
10
|
+
// test/agent-api.ts, shim ≡ stdio for all 29 tools) is the structural guard against a future re-divergence.
|
|
11
|
+
//
|
|
12
|
+
// Each function takes a hub connection + the caller's already-resolved+validated actor (server.ts resolves it
|
|
13
|
+
// from DEVLOOP_ACTOR + the G1 phantom-actor guard; the daemon from the X-Devloop-Actor header) and returns an
|
|
14
|
+
// HTTP-shaped { status, body }: the daemon serializes it as JSON; server.ts's toMcp() maps it to ok()/err()
|
|
15
|
+
// (a 200 → ok(body); a non-200 → err(body.error)). NO env read, NO mode gate, NO transport here — each
|
|
16
|
+
// transport owns its own pipeline (server.ts the stdio identity; the daemon op-API writeOriginOk → actor →
|
|
17
|
+
// mode-honoring) AROUND these pure-policy ops. (whoami + create_issue_label stay native in server.ts: whoami
|
|
18
|
+
// is transport-specific identity, not an op; create_issue_label's policy already lives once in labelstore, and
|
|
19
|
+
// the op logs an op-API-only label.create attribution event server.ts deliberately does not — DL-69 kept it
|
|
20
|
+
// native so the stdio path stays byte-identical.)
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { TOOL_NAMES } from "./tooldefs.js"; // DL-85: the ONE tool/op name source; AGENT_OPS derives from it
|
|
23
|
+
import { actorExists, listActorHandles, logEvent, unifiedDiff, STATES } from "./db.js";
|
|
24
|
+
import { insertTicket, updateTicketRow, insertComment, loadRelease } from "./ticketwrite.js";
|
|
25
|
+
// DL-62 doc/event family — the doc WRITES (docSave/docPublish, incl. the CAS + the single operator-publish
|
|
26
|
+
// gate) + the docstore-error→HTTP-status map are reused VERBATIM from the shared, side-effect-free docstore
|
|
27
|
+
// (exactly as the 5 ticket ops reuse ticketwrite.ts), so both transports share one publish gate + one CAS.
|
|
28
|
+
// The doc READS (doc.list/get/history/diff) + list_events are the SINGLE definition of those SELECTs — since
|
|
29
|
+
// DL-69 server.ts's handlers dispatch through them (no longer a 1:1 duplicate of a server.ts copy).
|
|
30
|
+
import { resolveDoc, latestVersion, docSave, docPublish, statusForDocErr, DOC_KINDS } from "./docstore.js";
|
|
31
|
+
// DL-64 discussion-board family — the topic/post reads + writes (incl. the §25 chair/invited role gates +
|
|
32
|
+
// the round/append rules) + the error→HTTP-status map are reused VERBATIM from the shared, side-effect-free
|
|
33
|
+
// topicstore (exactly as the doc family reuses docstore.ts), so the op-API and the stdio server.ts can never
|
|
34
|
+
// drift on a gate or a response shape. The op-API parses raw JSON, so each handler hand-validates the input
|
|
35
|
+
// shapes server.ts gets from zod (the DL-63 read-handler lesson — a non-string id/body must 400, never a 500).
|
|
36
|
+
import { topicList, topicGet, topicOpen, postAdd, topicSynthesize, topicClose, statusForTopicErr } from "./topicstore.js";
|
|
37
|
+
// DL-67 channel family — the channel register/send/poll/ack/status HANDLER logic + the DL-4 roadmap bridge are
|
|
38
|
+
// reused VERBATIM from the shared, side-effect-free channelstore (exactly as the doc/topic families reuse
|
|
39
|
+
// docstore/topicstore), so the op-API and the stdio server.ts can never drift. channel.send/poll are ASYNC
|
|
40
|
+
// (network/dryrun), so agentOp returns OpResult|Promise<OpResult> and the daemon awaits it. The op-API parses
|
|
41
|
+
// raw JSON → each handler hand-validates the shapes server.ts gets from zod (DL-63: a non-string arg → 400, never a 500).
|
|
42
|
+
import { channelRegister, channelSend, channelPoll, channelAck, channelStatus, statusForChannelErr } from "./channelstore.js";
|
|
43
|
+
// DL-68 P7 mirror + label/project — the FINAL slice. mirror.push's handler (reusing linear.ts's transport AS-IS)
|
|
44
|
+
// + mirror.status are reused VERBATIM from the shared mirrorstore (so the op-API + server.ts can't drift on the
|
|
45
|
+
// DL-11 DRYRUN invariant / reconcile-by-marker idempotency), and the label/project ops + the SINGLE LABEL_KINDS /
|
|
46
|
+
// DL-22 reject from labelstore. mirror.push is ASYNC (Linear network / dryrun build) → agentOp returns a Promise.
|
|
47
|
+
import { mirrorPush, mirrorStatus } from "./mirrorstore.js";
|
|
48
|
+
import { createLabel, listLabels, getProject } from "./labelstore.js";
|
|
49
|
+
const okR = (body) => ({ status: 200, body });
|
|
50
|
+
const errR = (status, error) => ({ status, body: { error } });
|
|
51
|
+
export const AGENT_OPS = TOOL_NAMES.filter((n) => n !== "whoami");
|
|
52
|
+
// The MUTATING subset — the daemon applies writeOriginOk + the dry-run mode gate to exactly these (reads
|
|
53
|
+
// never mutate, so they bypass both). Kept here next to AGENT_OPS so the two lists can't drift. doc.save /
|
|
54
|
+
// doc.publish join the ticket writes; the doc/event reads stay read-only (parity with the read ticket ops).
|
|
55
|
+
export const AGENT_WRITE_OPS = new Set(["save_issue", "save_comment", "doc.save", "doc.publish",
|
|
56
|
+
"topic.open", "post.add", "topic.synthesize", "topic.close", // DL-64: the 4 board writes
|
|
57
|
+
"channel.register", "channel.send", "channel.poll", "channel.ack", // DL-67: the 4 channel writes (register/send/poll/ack mutate the channels/channel_messages tables); channel.status stays a read (query_only)
|
|
58
|
+
"mirror.push", "create_issue_label"]); // DL-68: the 2 writes (mirror.push → mirror_map + the one-way Linear network write; create_issue_label → labels). mirror.status/list_issue_labels/get_project stay reads (query_only)
|
|
59
|
+
export const isAgentOp = (s) => AGENT_OPS.includes(s);
|
|
60
|
+
const toTicket = (r) => ({
|
|
61
|
+
id: r.id, project_id: r.project_id, title: r.title, description: r.description, type: r.type,
|
|
62
|
+
state: r.state, assignee: r.assignee, priority: r.priority,
|
|
63
|
+
labels: JSON.parse(r.labels),
|
|
64
|
+
duplicateOf: r.duplicate_of, relatedTo: JSON.parse(r.related_to),
|
|
65
|
+
created_by: r.created_by, created_at: r.created_at, updated_at: r.updated_at,
|
|
66
|
+
});
|
|
67
|
+
const getRow = (db, projectId, id) => db.prepare("SELECT * FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
|
|
68
|
+
// "me" → the caller's actor (the per-agent attribution win); empty/whitespace → unassigned; else verbatim.
|
|
69
|
+
const resolveAssignee = (actor, a) => a === undefined || a === null ? null
|
|
70
|
+
: a === "me" ? actor
|
|
71
|
+
: a.trim() === "" ? null
|
|
72
|
+
: a;
|
|
73
|
+
// ─── DL-24 per-transition assignTo directive (mirror of server.ts) ─────────────
|
|
74
|
+
const ownerHandleOf = (labels) => labels.includes("pm") ? "pm" : labels.includes("qa") ? "qa" : null;
|
|
75
|
+
function loadTransitions(db, projectId) {
|
|
76
|
+
try {
|
|
77
|
+
const row = db.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
|
|
78
|
+
const tr = (row?.settings_json ? JSON.parse(row.settings_json) : {})?.workflow?.transitions;
|
|
79
|
+
return tr && typeof tr === "object" ? tr : {};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return {};
|
|
83
|
+
} // malformed config ⇒ absent (fail-open), never bricks a write
|
|
84
|
+
}
|
|
85
|
+
function resolveAssignTo(db, projectId, actor, from, to, labels) {
|
|
86
|
+
const dir = loadTransitions(db, projectId)[`${from}->${to}`];
|
|
87
|
+
if (!dir || dir.assignTo === undefined || dir.assignTo === null)
|
|
88
|
+
return null;
|
|
89
|
+
const v = dir.assignTo;
|
|
90
|
+
if (v === "owner") {
|
|
91
|
+
const o = ownerHandleOf(labels);
|
|
92
|
+
if (!o)
|
|
93
|
+
console.error(`[assignTo] ${from}->${to}: owner directive but ticket has no pm/qa label — assignee left untouched`);
|
|
94
|
+
return o;
|
|
95
|
+
}
|
|
96
|
+
if (v === "self")
|
|
97
|
+
return actor;
|
|
98
|
+
if (actorExists(db, v))
|
|
99
|
+
return v;
|
|
100
|
+
console.error(`[assignTo] ${from}->${to}: unknown handle '${v}' — assignee left untouched`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
// ─── DL-32 prod-promotion gate (mirror of server.ts) ───────────────────────────
|
|
104
|
+
const ENV_LABELS = ["env:dev", "env:prod"];
|
|
105
|
+
const envLabelsOf = (labels) => labels.filter((l) => ENV_LABELS.includes(l)).sort();
|
|
106
|
+
function prodPromotionRejection(db, projectId, actor, oldLabels, newLabels) {
|
|
107
|
+
if (loadRelease(db, projectId).prodPromotionGate !== "human")
|
|
108
|
+
return null;
|
|
109
|
+
const adding = newLabels.includes("env:prod") && !oldLabels.includes("env:prod");
|
|
110
|
+
return adding && actor !== "operator"
|
|
111
|
+
? `env:prod promotion is human-gated (prodPromotionGate:"human"): only the operator may add env:prod`
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
// ─── the 5 ops ─────────────────────────────────────────────────────────────────
|
|
115
|
+
// Shared input-shape guard: a JSON array whose every element is a string — mirrors zod's z.array(z.string()).
|
|
116
|
+
// The op-API parses raw JSON (no zod), so list_issues + save_issue both re-check `labels` by hand with this
|
|
117
|
+
// (a non-array would crash a `[...]` spread or be JSON.stringify'd into the column → a 500); one definition the
|
|
118
|
+
// two ops share so they can't drift (DL-65 hoisted opSaveIssue's original local helper to module scope).
|
|
119
|
+
const isStrArr = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
120
|
+
function opListIssues(db, projectId, actor, a) {
|
|
121
|
+
// Re-validate the raw-JSON arg shapes the stdio path gets from zod (server.ts: query/assignee
|
|
122
|
+
// z.string().optional(), labels z.array(z.string()).optional()). Without this a non-string `query`
|
|
123
|
+
// (.toLowerCase() below), a non-array `labels` (the [...] spread below), or a non-string truthy `assignee`
|
|
124
|
+
// (resolveAssignee → .trim()) throws a TypeError → the daemon's catch → an HTTP 500 echoing the raw JS error,
|
|
125
|
+
// where the zod path returns a clean 400. Same guard class as opSaveIssue's labels / the doc-READ selectors
|
|
126
|
+
// (docSelectorErr, DL-63) — the last unguarded read op (DL-65). state/type/label are compared (never bound or
|
|
127
|
+
// method-called), so they keep today's behavior and need no guard.
|
|
128
|
+
if (a.query !== undefined && typeof a.query !== "string")
|
|
129
|
+
return errR(400, "query must be a string");
|
|
130
|
+
if (a.labels !== undefined && !isStrArr(a.labels))
|
|
131
|
+
return errR(400, "labels must be an array of strings");
|
|
132
|
+
if (a.assignee !== undefined && typeof a.assignee !== "string")
|
|
133
|
+
return errR(400, "assignee must be a string");
|
|
134
|
+
let out = db.prepare("SELECT * FROM tickets WHERE project_id=? ORDER BY updated_at DESC").all(projectId).map(toTicket);
|
|
135
|
+
if (a.state)
|
|
136
|
+
out = out.filter((t) => t.state === a.state);
|
|
137
|
+
if (a.assignee)
|
|
138
|
+
out = out.filter((t) => t.assignee === resolveAssignee(actor, a.assignee));
|
|
139
|
+
if (a.type)
|
|
140
|
+
out = out.filter((t) => t.type === a.type);
|
|
141
|
+
const want = [...(a.labels ?? []), ...(a.label ? [a.label] : [])];
|
|
142
|
+
if (want.length)
|
|
143
|
+
out = out.filter((t) => want.every((l) => t.labels.includes(l)));
|
|
144
|
+
if (a.query) {
|
|
145
|
+
const q = a.query.toLowerCase();
|
|
146
|
+
out = out.filter((t) => t.title.toLowerCase().includes(q) || t.description.toLowerCase().includes(q));
|
|
147
|
+
}
|
|
148
|
+
return okR(a.limit ? out.slice(0, a.limit) : out);
|
|
149
|
+
}
|
|
150
|
+
function opGetIssue(db, projectId, projectKey, a) {
|
|
151
|
+
if (a.id === undefined)
|
|
152
|
+
return errR(400, "id required"); // === undefined, NOT falsy: a zod-valid empty-string id ("" passes the bare z.string()) must fall through to the not-found lookup, byte-identical to the pre-DL-69 native handler; this guard exists only to stop an undefined → node:sqlite bind-crash
|
|
153
|
+
const r = getRow(db, projectId, a.id);
|
|
154
|
+
if (!r)
|
|
155
|
+
return errR(404, `no such ticket ${a.id} in ${projectKey}`);
|
|
156
|
+
const comments = db.prepare("SELECT id,author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(a.id);
|
|
157
|
+
return okR({ ...toTicket(r), comments });
|
|
158
|
+
}
|
|
159
|
+
// MIRRORS server.ts save_issue exactly: validate → create (insertTicket) OR update (atomic read-merge-write
|
|
160
|
+
// under BEGIN IMMEDIATE: REPLACE labels, APPEND-only relatedTo union, DL-24 assignTo, DL-32 promo gate, the
|
|
161
|
+
// DL-38 staging gate inside updateTicketRow, the issue.promote env event). `db` MUST be a WRITABLE connection.
|
|
162
|
+
function opSaveIssue(db, projectId, projectKey, actor, a) {
|
|
163
|
+
// Input validation the stdio path gets from its zod schema (server.ts) — the op-API parses raw JSON, so it
|
|
164
|
+
// re-checks the SAME shapes by hand. The array fields are load-bearing: a non-array labels/relatedTo would
|
|
165
|
+
// be JSON.stringify'd into the column and later crash a `t.labels.includes()` / `[...]` spread (a 500
|
|
166
|
+
// poison-pill on every subsequent list_issues), so reject them up front — matching zod's array-of-strings.
|
|
167
|
+
if (a.labels !== undefined && !isStrArr(a.labels))
|
|
168
|
+
return errR(400, "labels must be an array of strings");
|
|
169
|
+
if (a.relatedTo !== undefined && !isStrArr(a.relatedTo))
|
|
170
|
+
return errR(400, "relatedTo must be an array of strings");
|
|
171
|
+
if (a.priority !== undefined && (typeof a.priority !== "number" || !Number.isInteger(a.priority) || a.priority < 0 || a.priority > 4))
|
|
172
|
+
return errR(400, `invalid priority; an integer 0..4`);
|
|
173
|
+
if (a.state && !STATES.includes(a.state))
|
|
174
|
+
return errR(400, `invalid state '${a.state}'; one of ${STATES.join(", ")}`);
|
|
175
|
+
if (a.assignee && a.assignee !== "me" && !actorExists(db, a.assignee))
|
|
176
|
+
return errR(400, `unknown assignee '${a.assignee}'; one of ${listActorHandles(db).join(", ")} (or "me"/null)`); // DL-69: the message is byte-identical to server.ts's (the single source) — agent-api.ts asserts only status 400
|
|
177
|
+
if (!a.id) {
|
|
178
|
+
if (!a.title)
|
|
179
|
+
return errR(400, "title required to create a ticket");
|
|
180
|
+
const promoReject = prodPromotionRejection(db, projectId, actor, [], a.labels ?? []);
|
|
181
|
+
if (promoReject)
|
|
182
|
+
return errR(403, promoReject);
|
|
183
|
+
const id = insertTicket(db, projectId, actor, { title: a.title, description: a.description ?? "", type: a.type ?? "Feature", state: a.state ?? "Todo",
|
|
184
|
+
assignee: resolveAssignee(actor, a.assignee), priority: a.priority ?? 0, labels: a.labels ?? [],
|
|
185
|
+
duplicateOf: a.duplicateOf ?? null, relatedTo: a.relatedTo ?? [] }, { title: a.title, type: a.type });
|
|
186
|
+
return okR(toTicket(getRow(db, projectId, id)));
|
|
187
|
+
}
|
|
188
|
+
// update — atomic read-merge-write (the APPEND-only relatedTo union must not lose a concurrent link).
|
|
189
|
+
db.exec("BEGIN IMMEDIATE");
|
|
190
|
+
try {
|
|
191
|
+
const cur = getRow(db, projectId, a.id);
|
|
192
|
+
if (!cur) {
|
|
193
|
+
db.exec("ROLLBACK");
|
|
194
|
+
return errR(404, `no such ticket ${a.id} in ${projectKey}`);
|
|
195
|
+
}
|
|
196
|
+
const next = {
|
|
197
|
+
title: a.title ?? cur.title, description: a.description ?? cur.description, type: a.type ?? cur.type,
|
|
198
|
+
state: a.state ?? cur.state,
|
|
199
|
+
assignee: a.assignee === undefined ? cur.assignee : resolveAssignee(actor, a.assignee),
|
|
200
|
+
priority: a.priority ?? cur.priority,
|
|
201
|
+
labels: a.labels ? JSON.stringify(a.labels) : cur.labels, // REPLACE-style (§10#1)
|
|
202
|
+
duplicate_of: a.duplicateOf === undefined ? cur.duplicate_of : a.duplicateOf, // scalar; undefined=keep
|
|
203
|
+
related_to: a.relatedTo // APPEND-only union (§18)
|
|
204
|
+
? JSON.stringify([...new Set([...JSON.parse(cur.related_to), ...a.relatedTo])])
|
|
205
|
+
: cur.related_to,
|
|
206
|
+
};
|
|
207
|
+
if (next.state !== cur.state && a.assignee === undefined) { // DL-24 assignTo (implicit assignee only)
|
|
208
|
+
const resolved = resolveAssignTo(db, projectId, actor, cur.state, next.state, JSON.parse(next.labels));
|
|
209
|
+
if (resolved !== null)
|
|
210
|
+
next.assignee = resolved;
|
|
211
|
+
}
|
|
212
|
+
const oldLabels = JSON.parse(cur.labels), newLabels = JSON.parse(next.labels);
|
|
213
|
+
const promoReject = prodPromotionRejection(db, projectId, actor, oldLabels, newLabels); // DL-32 prod gate
|
|
214
|
+
if (promoReject) {
|
|
215
|
+
db.exec("ROLLBACK");
|
|
216
|
+
return errR(403, promoReject);
|
|
217
|
+
}
|
|
218
|
+
const wr = updateTicketRow(db, projectId, actor, a.id, cur.state, next); // DL-38 staging gate inside ⇒ may reject
|
|
219
|
+
if (!wr.ok) {
|
|
220
|
+
db.exec("ROLLBACK");
|
|
221
|
+
return errR(wr.status, wr.error);
|
|
222
|
+
}
|
|
223
|
+
const fromEnv = envLabelsOf(oldLabels).join(","), toEnv = envLabelsOf(newLabels).join(","); // DL-32 issue.promote on env change
|
|
224
|
+
if (fromEnv !== toEnv)
|
|
225
|
+
logEvent(db, { project_id: projectId, ticket_id: a.id, actor, kind: "issue.promote", data: { from: fromEnv, to: toEnv } });
|
|
226
|
+
db.exec("COMMIT");
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
try {
|
|
230
|
+
db.exec("ROLLBACK");
|
|
231
|
+
}
|
|
232
|
+
catch { /* */ }
|
|
233
|
+
throw e;
|
|
234
|
+
}
|
|
235
|
+
return okR(toTicket(getRow(db, projectId, a.id)));
|
|
236
|
+
}
|
|
237
|
+
// `db` MUST be a WRITABLE connection (the comment INSERT + comment.add event go through insertComment).
|
|
238
|
+
function opSaveComment(db, projectId, actor, a) {
|
|
239
|
+
if (a.issueId === undefined)
|
|
240
|
+
return errR(400, "issueId required"); // === undefined, NOT falsy (DL-69): a zod-valid empty-string issueId must fall through to the not-found lookup, byte-identical to the pre-refactor native handler
|
|
241
|
+
if (typeof a.body !== "string")
|
|
242
|
+
return errR(400, "body required");
|
|
243
|
+
if (!getRow(db, projectId, a.issueId))
|
|
244
|
+
return errR(404, `no such ticket ${a.issueId}`);
|
|
245
|
+
const { id, createdAt } = insertComment(db, projectId, actor, a.issueId, a.body);
|
|
246
|
+
return okR({ id, ticket_id: a.issueId, author: actor, body: a.body, created_at: createdAt });
|
|
247
|
+
}
|
|
248
|
+
function opListComments(db, projectId, projectKey, a) {
|
|
249
|
+
if (a.issueId === undefined)
|
|
250
|
+
return errR(400, "issueId required"); // === undefined, NOT falsy (DL-69): a zod-valid empty-string issueId must fall through to the not-found lookup, byte-identical to the pre-refactor native handler
|
|
251
|
+
if (!getRow(db, projectId, a.issueId))
|
|
252
|
+
return errR(404, `no such ticket ${a.issueId} in ${projectKey}`);
|
|
253
|
+
return okR(db.prepare("SELECT id,author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(a.issueId));
|
|
254
|
+
}
|
|
255
|
+
// ─── DL-62: the doc/event family (verbatim mirror of server.ts list_events + doc.* handlers) ──────
|
|
256
|
+
// The doc READS + list_events are the SAME SELECTs server.ts runs (a JSON round-trip → byte-identical
|
|
257
|
+
// to the stdio ok() body — the differential-parity tripwire). The doc WRITES delegate to the shared
|
|
258
|
+
// docstore (docSave/docPublish), so the CAS + the single operator-publish gate live in ONE place.
|
|
259
|
+
function opListEvents(db, projectId, a) {
|
|
260
|
+
// mirror server.ts's zod (limit: int 1..500) — the op-API parses raw JSON, so a bad limit must be a clean
|
|
261
|
+
// 400 here, never bound into LIMIT (a non-int bind throws in node:sqlite → a 500; an uncapped limit drifts).
|
|
262
|
+
if (a.limit !== undefined && (!Number.isInteger(a.limit) || a.limit <= 0 || a.limit > 500))
|
|
263
|
+
return errR(400, "limit must be an integer 1..500");
|
|
264
|
+
return okR(db.prepare("SELECT actor,kind,ticket_id,data,created_at FROM events WHERE project_id=? ORDER BY id DESC LIMIT ?").all(projectId, a.limit ?? 50));
|
|
265
|
+
}
|
|
266
|
+
// Mirror server.ts's zod (the doc tools' `slug`/`kind` are OPTIONAL STRINGS). The op-API parses raw JSON
|
|
267
|
+
// with no zod, so a present-but-non-string slug/kind must 400 HERE — otherwise it binds into resolveDoc's
|
|
268
|
+
// parameterized query and node:sqlite throws "Provided value cannot be bound" → an HTTP 500 echoing the raw
|
|
269
|
+
// driver string (same class as opSaveIssue's non-array / opDocDiff's non-int guards, extended to the doc-READ
|
|
270
|
+
// selectors — DL-63). Absent (undefined) is fine: a read selects by slug OR kind, and doc.list by neither.
|
|
271
|
+
const docSelectorErr = (a) => a.slug !== undefined && typeof a.slug !== "string" ? "slug must be a string"
|
|
272
|
+
: a.kind !== undefined && typeof a.kind !== "string" ? "kind must be a string"
|
|
273
|
+
: null;
|
|
274
|
+
function opDocList(db, projectId, a) {
|
|
275
|
+
const bad = docSelectorErr(a);
|
|
276
|
+
if (bad)
|
|
277
|
+
return errR(400, bad);
|
|
278
|
+
return okR(a.kind
|
|
279
|
+
? db.prepare("SELECT id,kind,slug,title,status,current_version,created_by,updated_at FROM documents WHERE project_id=? AND kind=? ORDER BY kind").all(projectId, a.kind)
|
|
280
|
+
: db.prepare("SELECT id,kind,slug,title,status,current_version,created_by,updated_at FROM documents WHERE project_id=? ORDER BY kind").all(projectId));
|
|
281
|
+
}
|
|
282
|
+
function opDocGet(db, projectId, projectKey, a) {
|
|
283
|
+
const bad = docSelectorErr(a);
|
|
284
|
+
if (bad)
|
|
285
|
+
return errR(400, bad);
|
|
286
|
+
// mirror server.ts's zod (version: int>0, optional). Re-check by hand (no zod on the op-API path): an
|
|
287
|
+
// out-of-range version must 400 like the stdio path, not fall through to the version===0 empty-doc branch.
|
|
288
|
+
if (a.version !== undefined && (!Number.isInteger(a.version) || a.version <= 0))
|
|
289
|
+
return errR(400, "version must be a positive integer");
|
|
290
|
+
const d = resolveDoc(db, projectId, a.slug, a.kind);
|
|
291
|
+
if (!d)
|
|
292
|
+
return errR(404, `no document ${a.slug ?? a.kind} in ${projectKey}`);
|
|
293
|
+
const ver = a.version ?? (d.current_version > 0 ? d.current_version : latestVersion(db, d.id));
|
|
294
|
+
if (ver === 0)
|
|
295
|
+
return okR({ ...d, version: 0, body: "", unpublished: true, empty: true });
|
|
296
|
+
const v = db.prepare("SELECT version,body,status,summary,base_version,author,created_at FROM document_versions WHERE doc_id=? AND version=?").get(d.id, ver);
|
|
297
|
+
if (!v)
|
|
298
|
+
return errR(404, `no version ${ver} of ${d.slug}`);
|
|
299
|
+
return okR({ id: d.id, kind: d.kind, slug: d.slug, title: d.title, status: d.status, current_version: d.current_version, ...v, ...(d.current_version === 0 ? { unpublished: true } : {}) });
|
|
300
|
+
}
|
|
301
|
+
function opDocHistory(db, projectId, a) {
|
|
302
|
+
const bad = docSelectorErr(a);
|
|
303
|
+
if (bad)
|
|
304
|
+
return errR(400, bad);
|
|
305
|
+
const d = resolveDoc(db, projectId, a.slug, a.kind);
|
|
306
|
+
if (!d)
|
|
307
|
+
return errR(404, `no document ${a.slug ?? a.kind}`);
|
|
308
|
+
return okR(db.prepare("SELECT version,status,author,summary,base_version,created_at FROM document_versions WHERE doc_id=? ORDER BY version DESC").all(d.id));
|
|
309
|
+
}
|
|
310
|
+
function opDocDiff(db, projectId, a) {
|
|
311
|
+
const bad = docSelectorErr(a);
|
|
312
|
+
if (bad)
|
|
313
|
+
return errR(400, bad);
|
|
314
|
+
// from/to come from zod (int>0) on the stdio/shim path; the op-API parses raw JSON, so re-check by hand —
|
|
315
|
+
// a non-int bind would otherwise throw inside node:sqlite → a 500 instead of a clean 400 (opSaveIssue precedent).
|
|
316
|
+
if (!Number.isInteger(a.from) || a.from <= 0)
|
|
317
|
+
return errR(400, "from must be a positive integer");
|
|
318
|
+
if (!Number.isInteger(a.to) || a.to <= 0)
|
|
319
|
+
return errR(400, "to must be a positive integer");
|
|
320
|
+
const d = resolveDoc(db, projectId, a.slug, a.kind);
|
|
321
|
+
if (!d)
|
|
322
|
+
return errR(404, `no document ${a.slug ?? a.kind}`);
|
|
323
|
+
const body = (n) => db.prepare("SELECT body FROM document_versions WHERE doc_id=? AND version=?").get(d.id, n)?.body;
|
|
324
|
+
const fromBody = body(a.from), toBody = body(a.to);
|
|
325
|
+
if (fromBody === undefined || toBody === undefined)
|
|
326
|
+
return errR(404, `missing version (have up to ${latestVersion(db, d.id)})`);
|
|
327
|
+
return okR({ from: a.from, to: a.to, fromBody, toBody, unified: unifiedDiff(fromBody, toBody) });
|
|
328
|
+
}
|
|
329
|
+
// `db` MUST be a WRITABLE connection (docSave does BEGIN IMMEDIATE + INSERTs + a doc.save event). The CAS
|
|
330
|
+
// (a stale baseVersion → CONFLICT, never last-write-wins) lives inside docSave, shared with server.ts.
|
|
331
|
+
function opDocSave(db, projectId, actor, a) {
|
|
332
|
+
// re-validate the zod shapes the stdio/shim path enforces (slug/body required, kind ∈ DOC_KINDS, baseVersion int≥0)
|
|
333
|
+
if (typeof a.slug !== "string")
|
|
334
|
+
return errR(400, "slug required (a string)"); // type-only, NOT non-empty (DL-69): a zod-valid empty-string slug must reach docSave (which creates/handles it), byte-identical to the pre-refactor native handler; only undefined/non-string is rejected (the INSERT-bind guard)
|
|
335
|
+
if (typeof a.body !== "string")
|
|
336
|
+
return errR(400, "body required (a string)");
|
|
337
|
+
if (a.title !== undefined && typeof a.title !== "string")
|
|
338
|
+
return errR(400, "title must be a string"); // server.ts zod: title/summary optional strings — a non-string would bind into the INSERT → a 500
|
|
339
|
+
if (a.summary !== undefined && typeof a.summary !== "string")
|
|
340
|
+
return errR(400, "summary must be a string");
|
|
341
|
+
if (!Number.isInteger(a.baseVersion) || a.baseVersion < 0)
|
|
342
|
+
return errR(400, "baseVersion must be a non-negative integer");
|
|
343
|
+
if (!DOC_KINDS.includes(a.kind))
|
|
344
|
+
return errR(400, `invalid kind '${a.kind}'; one of ${DOC_KINDS.join(", ")}`);
|
|
345
|
+
const r = docSave(db, projectId, actor, a);
|
|
346
|
+
return r.ok ? okR(r.data) : errR(statusForDocErr(r.error), r.error);
|
|
347
|
+
}
|
|
348
|
+
// `db` MUST be a WRITABLE connection (docPublish does BEGIN IMMEDIATE + UPDATEs + a doc.publish event). The
|
|
349
|
+
// OPERATOR-only gate lives inside docPublish (shared with server.ts) — cooperative role-attribution, not
|
|
350
|
+
// anti-spoof on one host (§18): only the actor the daemon resolved from X-Devloop-Actor as "operator" passes.
|
|
351
|
+
function opDocPublish(db, projectId, actor, a) {
|
|
352
|
+
if (!Number.isInteger(a.version) || a.version <= 0)
|
|
353
|
+
return errR(400, "version must be a positive integer");
|
|
354
|
+
const r = docPublish(db, projectId, actor, a);
|
|
355
|
+
return r.ok ? okR(r.data) : errR(statusForDocErr(r.error), r.error);
|
|
356
|
+
}
|
|
357
|
+
// ─── DL-64: the discussion-board family (topic.*/post.add) — thin op-API wrappers over the shared topicstore ──
|
|
358
|
+
// Mirror the doc-family pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these from
|
|
359
|
+
// zod), then delegate to topicstore (which owns the §25 role gates + round/append rules); a TopicResult error
|
|
360
|
+
// maps to its HTTP status via statusForTopicErr. The reads (topic.list/topic.get) take the query_only db; the
|
|
361
|
+
// writes (topic.open/post.add/topic.synthesize/topic.close ∈ AGENT_WRITE_OPS) take writeDb — the daemon routes.
|
|
362
|
+
function opTopicList(db, projectId, actor, a) {
|
|
363
|
+
if (a.status !== undefined && a.status !== "open" && a.status !== "closed")
|
|
364
|
+
return errR(400, `status must be "open" or "closed"`);
|
|
365
|
+
return okR(topicList(db, projectId, actor, a.status));
|
|
366
|
+
}
|
|
367
|
+
function opTopicGet(db, projectId, projectKey, a) {
|
|
368
|
+
if (typeof a.id !== "string")
|
|
369
|
+
return errR(400, "id must be a string");
|
|
370
|
+
const r = topicGet(db, projectId, projectKey, a.id);
|
|
371
|
+
return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
|
|
372
|
+
}
|
|
373
|
+
function opTopicOpen(db, projectId, actor, a) {
|
|
374
|
+
if (typeof a.question !== "string" || !a.question)
|
|
375
|
+
return errR(400, "question required (a non-empty string)");
|
|
376
|
+
if (!isStrArr(a.invited) || a.invited.length === 0)
|
|
377
|
+
return errR(400, "invited required (a non-empty array of strings)");
|
|
378
|
+
const r = topicOpen(db, projectId, actor, a);
|
|
379
|
+
return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
|
|
380
|
+
}
|
|
381
|
+
function opPostAdd(db, projectId, projectKey, actor, a) {
|
|
382
|
+
if (typeof a.topicId !== "string")
|
|
383
|
+
return errR(400, "topicId must be a string");
|
|
384
|
+
if (typeof a.body !== "string" || !a.body)
|
|
385
|
+
return errR(400, "body required (a non-empty string)");
|
|
386
|
+
const r = postAdd(db, projectId, projectKey, actor, a);
|
|
387
|
+
return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
|
|
388
|
+
}
|
|
389
|
+
function opTopicSynthesize(db, projectId, projectKey, actor, a) {
|
|
390
|
+
if (typeof a.topicId !== "string")
|
|
391
|
+
return errR(400, "topicId must be a string");
|
|
392
|
+
if (typeof a.body !== "string" || !a.body)
|
|
393
|
+
return errR(400, "body required (a non-empty string)");
|
|
394
|
+
if (a.nextRound !== undefined && typeof a.nextRound !== "boolean")
|
|
395
|
+
return errR(400, "nextRound must be a boolean");
|
|
396
|
+
const r = topicSynthesize(db, projectId, projectKey, actor, a);
|
|
397
|
+
return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
|
|
398
|
+
}
|
|
399
|
+
function opTopicClose(db, projectId, projectKey, actor, a) {
|
|
400
|
+
if (typeof a.topicId !== "string")
|
|
401
|
+
return errR(400, "topicId must be a string");
|
|
402
|
+
if (typeof a.decision !== "string" || !a.decision)
|
|
403
|
+
return errR(400, "decision required (a non-empty string)");
|
|
404
|
+
const r = topicClose(db, projectId, projectKey, actor, a);
|
|
405
|
+
return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
|
|
406
|
+
}
|
|
407
|
+
// ─── DL-67: the IM channel family (channel.*) — thin op-API wrappers over the shared channelstore ──
|
|
408
|
+
// Mirror the doc/topic pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these from
|
|
409
|
+
// zod — the DL-63 lesson), then delegate to channelstore (which owns the §16 line-build, the DL-4 roadmap
|
|
410
|
+
// bridge, the per-process send cap, and the channels/channel_messages writes); a ChannelResult error maps to
|
|
411
|
+
// its HTTP status via statusForChannelErr. channel.send/poll are ASYNC (network/dryrun) → they return Promises.
|
|
412
|
+
// channel.status is the only READ (query_only db, not in AGENT_WRITE_OPS); the 4 writes take writeDb.
|
|
413
|
+
function opChannelRegister(db, projectId, actor, a) {
|
|
414
|
+
if (a.provider !== "slack" && a.provider !== "lark")
|
|
415
|
+
return errR(400, `provider must be "slack" or "lark"`);
|
|
416
|
+
if (typeof a.configRef !== "string" || !a.configRef)
|
|
417
|
+
return errR(400, "configRef required (a non-empty string)");
|
|
418
|
+
if (a.secretRef !== undefined && typeof a.secretRef !== "string")
|
|
419
|
+
return errR(400, "secretRef must be a string");
|
|
420
|
+
if (typeof a.channelRef !== "string" || !a.channelRef)
|
|
421
|
+
return errR(400, "channelRef required (a non-empty string)");
|
|
422
|
+
const r = channelRegister(db, projectId, actor, a);
|
|
423
|
+
return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
|
|
424
|
+
}
|
|
425
|
+
// ASYNC (channelSend awaits sendVia / the DRYRUN build). Hand-validate the fields channelSend method-calls or
|
|
426
|
+
// binds (a wrong type would 500): ticketId (a SELECT bind), text + digest.headline (cleanLine→.replace),
|
|
427
|
+
// digest.openProposals (.slice/.map + cleanLine each). The numeric digest fields only feed template strings
|
|
428
|
+
// (coerced), so they need no guard — parity with the doc-family numeric handling.
|
|
429
|
+
async function opChannelSend(db, projectId, projectKey, actor, a) {
|
|
430
|
+
if (a.kind !== "notify" && a.kind !== "digest" && a.kind !== "reply")
|
|
431
|
+
return errR(400, `kind must be one of notify, digest, reply`);
|
|
432
|
+
if (a.ticketId !== undefined && typeof a.ticketId !== "string")
|
|
433
|
+
return errR(400, "ticketId must be a string");
|
|
434
|
+
if (a.text !== undefined && typeof a.text !== "string")
|
|
435
|
+
return errR(400, "text must be a string");
|
|
436
|
+
if (a.bailShape !== undefined && typeof a.bailShape !== "string")
|
|
437
|
+
return errR(400, "bailShape must be a string");
|
|
438
|
+
if (a.digest !== undefined) {
|
|
439
|
+
if (typeof a.digest !== "object" || a.digest === null || Array.isArray(a.digest))
|
|
440
|
+
return errR(400, "digest must be an object");
|
|
441
|
+
const d = a.digest;
|
|
442
|
+
if (d.headline !== undefined && typeof d.headline !== "string")
|
|
443
|
+
return errR(400, "digest.headline must be a string");
|
|
444
|
+
if (d.openProposals !== undefined && !isStrArr(d.openProposals))
|
|
445
|
+
return errR(400, "digest.openProposals must be an array of strings");
|
|
446
|
+
}
|
|
447
|
+
const r = await channelSend(db, projectId, projectKey, actor, a);
|
|
448
|
+
return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
|
|
449
|
+
}
|
|
450
|
+
// ASYNC (channelPoll awaits pollVia / the DRYRUN build + the DL-4 bridge). No input (the fixture rides env).
|
|
451
|
+
async function opChannelPoll(db, projectId, projectKey, actor) {
|
|
452
|
+
const r = await channelPoll(db, projectId, projectKey, actor);
|
|
453
|
+
return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
|
|
454
|
+
}
|
|
455
|
+
function opChannelAck(db, projectId, projectKey, actor, a) {
|
|
456
|
+
if (typeof a.messageId !== "string")
|
|
457
|
+
return errR(400, "messageId must be a string");
|
|
458
|
+
if (a.actedInto !== undefined && typeof a.actedInto !== "string")
|
|
459
|
+
return errR(400, "actedInto must be a string");
|
|
460
|
+
const r = channelAck(db, projectId, projectKey, actor, a);
|
|
461
|
+
return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
|
|
462
|
+
}
|
|
463
|
+
function opChannelStatus(db, projectId) {
|
|
464
|
+
return okR(channelStatus(db, projectId)); // read; never origin/actor-gated upstream (parity with the read ticket/doc/topic ops)
|
|
465
|
+
}
|
|
466
|
+
// ─── DL-68: P7 mirror (mirror.push/mirror.status) + label/project (list_issue_labels/create_issue_label/
|
|
467
|
+
// get_project) — thin op-API wrappers over the shared mirrorstore/labelstore ──
|
|
468
|
+
// Mirror the doc/topic/channel pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these
|
|
469
|
+
// from zod — the DL-63/DL-65 lesson), then delegate to the shared store. mirror.push / create_issue_label errors
|
|
470
|
+
// are all client 400s (bad input / unset-or-literal token / DL-22 empty-name / bad-kind) — no 404/409 here, and
|
|
471
|
+
// a failed Linear network call is counted in `failed`, never an op error. mirror.push is ASYNC (Linear network /
|
|
472
|
+
// dryrun build) → it returns a Promise. The 3 reads take the query_only db (not in AGENT_WRITE_OPS).
|
|
473
|
+
// ASYNC (mirrorPush awaits the Linear transport / the DRYRUN build). Hand-validate the shapes server.ts gets
|
|
474
|
+
// from zod (teamId/tokenEnv non-empty strings, projectId optional string, stateMap an object, limit int 1..500)
|
|
475
|
+
// so a bad type is a clean 400, never a node:sqlite bind-throw 500 or a crash inside mirrorPush.
|
|
476
|
+
async function opMirrorPush(db, projectId, actor, a) {
|
|
477
|
+
if (typeof a.teamId !== "string" || !a.teamId)
|
|
478
|
+
return errR(400, "teamId required (a non-empty string)");
|
|
479
|
+
if (typeof a.tokenEnv !== "string" || !a.tokenEnv)
|
|
480
|
+
return errR(400, "tokenEnv required (a non-empty string)");
|
|
481
|
+
if (a.projectId !== undefined && typeof a.projectId !== "string")
|
|
482
|
+
return errR(400, "projectId must be a string");
|
|
483
|
+
if (a.stateMap !== undefined && (typeof a.stateMap !== "object" || a.stateMap === null || Array.isArray(a.stateMap)))
|
|
484
|
+
return errR(400, "stateMap must be an object");
|
|
485
|
+
if (a.limit !== undefined && (!Number.isInteger(a.limit) || a.limit < 1 || a.limit > 500))
|
|
486
|
+
return errR(400, "limit must be an integer 1..500");
|
|
487
|
+
const r = await mirrorPush(db, projectId, actor, a);
|
|
488
|
+
return r.ok ? okR(r.data) : errR(400, r.error); // §16-safe error (isEnvName / scrubErr inside mirrorstore); the token never appears
|
|
489
|
+
}
|
|
490
|
+
function opMirrorStatus(db, projectId) {
|
|
491
|
+
return okR(mirrorStatus(db, projectId)); // read; coverage counts, no secret, no Linear read
|
|
492
|
+
}
|
|
493
|
+
function opListLabels(db, projectId) {
|
|
494
|
+
return okR(listLabels(db, projectId)); // read
|
|
495
|
+
}
|
|
496
|
+
// `db` MUST be a WRITABLE connection. Validation (DL-22 empty-name + LABEL_KINDS) lives in the shared createLabel
|
|
497
|
+
// so server.ts + the op-API can't drift. The attributed `label.create` event is logged HERE (the identity win).
|
|
498
|
+
// DL-69 note: this is the ONE op server.ts does NOT dispatch through — its create_issue_label stays a native
|
|
499
|
+
// createLabel call that logs NO event, so the stdio path stays byte-identical (routing it here would ADD that
|
|
500
|
+
// event to stdio = new behavior, out of scope for a behavior-preserving refactor). Unifying that attribution
|
|
501
|
+
// onto the stdio path is a deliberate behavior change for a follow-up; both paths return {name,kind} identically.
|
|
502
|
+
function opCreateLabel(db, projectId, actor, a) {
|
|
503
|
+
if (typeof a.name !== "string")
|
|
504
|
+
return errR(400, "name required (a string)"); // server.ts zod: name z.string() — a non-string would crash createLabel's .trim()
|
|
505
|
+
if (a.kind !== undefined && typeof a.kind !== "string")
|
|
506
|
+
return errR(400, "kind must be a string");
|
|
507
|
+
const r = createLabel(db, projectId, a);
|
|
508
|
+
if (!r.ok)
|
|
509
|
+
return errR(400, r.error); // DL-22: empty-name / bad-kind → a clean 400, never a fake success with a dropped row
|
|
510
|
+
logEvent(db, { project_id: projectId, actor, kind: "label.create", data: { name: r.data.name, kind: r.data.kind } });
|
|
511
|
+
return okR(r.data);
|
|
512
|
+
}
|
|
513
|
+
function opGetProject(db, projectId) {
|
|
514
|
+
return okR(getProject(db, projectId)); // read
|
|
515
|
+
}
|
|
516
|
+
// Dispatch one op. `db` is the WRITABLE connection for the write ops (save_issue/save_comment) and may be
|
|
517
|
+
// the daemon's query_only read connection for the read ops — the daemon passes the right one per op. `actor`
|
|
518
|
+
// is already resolved+validated by the daemon (the G1 guard). `args` is the parsed JSON body (a non-object
|
|
519
|
+
// body is normalized to {} by the caller). Throws only on a genuine DB fault (→ the daemon's 500 catch).
|
|
520
|
+
export function agentOp(op, db, projectId, projectKey, actor, args) {
|
|
521
|
+
switch (op) {
|
|
522
|
+
case "list_issues": return opListIssues(db, projectId, actor, args);
|
|
523
|
+
case "get_issue": return opGetIssue(db, projectId, projectKey, args);
|
|
524
|
+
case "save_issue": return opSaveIssue(db, projectId, projectKey, actor, args);
|
|
525
|
+
case "save_comment": return opSaveComment(db, projectId, actor, args);
|
|
526
|
+
case "list_comments": return opListComments(db, projectId, projectKey, args);
|
|
527
|
+
case "list_events": return opListEvents(db, projectId, args);
|
|
528
|
+
case "doc.list": return opDocList(db, projectId, args);
|
|
529
|
+
case "doc.get": return opDocGet(db, projectId, projectKey, args);
|
|
530
|
+
case "doc.history": return opDocHistory(db, projectId, args);
|
|
531
|
+
case "doc.diff": return opDocDiff(db, projectId, args);
|
|
532
|
+
case "doc.save": return opDocSave(db, projectId, actor, args);
|
|
533
|
+
case "doc.publish": return opDocPublish(db, projectId, actor, args);
|
|
534
|
+
case "topic.list": return opTopicList(db, projectId, actor, args);
|
|
535
|
+
case "topic.get": return opTopicGet(db, projectId, projectKey, args);
|
|
536
|
+
case "topic.open": return opTopicOpen(db, projectId, actor, args);
|
|
537
|
+
case "post.add": return opPostAdd(db, projectId, projectKey, actor, args);
|
|
538
|
+
case "topic.synthesize": return opTopicSynthesize(db, projectId, projectKey, actor, args);
|
|
539
|
+
case "topic.close": return opTopicClose(db, projectId, projectKey, actor, args);
|
|
540
|
+
case "channel.register": return opChannelRegister(db, projectId, actor, args);
|
|
541
|
+
case "channel.send": return opChannelSend(db, projectId, projectKey, actor, args);
|
|
542
|
+
case "channel.poll": return opChannelPoll(db, projectId, projectKey, actor);
|
|
543
|
+
case "channel.ack": return opChannelAck(db, projectId, projectKey, actor, args);
|
|
544
|
+
case "channel.status": return opChannelStatus(db, projectId);
|
|
545
|
+
case "mirror.push": return opMirrorPush(db, projectId, actor, args);
|
|
546
|
+
case "mirror.status": return opMirrorStatus(db, projectId);
|
|
547
|
+
case "list_issue_labels": return opListLabels(db, projectId);
|
|
548
|
+
case "create_issue_label": return opCreateLabel(db, projectId, actor, args);
|
|
549
|
+
case "get_project": return opGetProject(db, projectId);
|
|
550
|
+
}
|
|
551
|
+
}
|