superdev-cli 0.2.0 → 0.3.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli/render.mjs +4 -0
- package/src/cli.mjs +102 -6
- package/src/db/migrations/014_task_superseded_by.sql +19 -0
- package/src/docs/render.mjs +3 -2
- package/src/docs/templates.mjs +1 -1
- package/src/model/vocabulary.mjs +26 -0
- package/src/progress/index.mjs +13 -5
- package/src/runtime/hooks.mjs +84 -5
- package/src/service/manage.mjs +42 -5
- package/src/tasks/lifecycle.mjs +120 -33
- package/src/tasks/merge.mjs +250 -0
- package/src/verify/index.mjs +86 -5
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Superdev - tell it what you want to build, and it keeps the product map, documentation, decisions and status in step while specialist providers do the specialist work.",
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.3.0",
|
|
9
9
|
"homepage": "https://github.com/superdev-ai/superdev"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "superdev",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence - plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.3.0",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Rahul Retnan"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rahul Retnan"
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
"homepage": "https://github.com/superdev-ai/superdev",
|
|
17
17
|
"repository": "https://github.com/superdev-ai/superdev",
|
|
18
18
|
"requires": {
|
|
19
|
-
"cli": "0.
|
|
19
|
+
"cli": "0.3.0"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rahul Retnan"
|
|
@@ -22,6 +22,6 @@
|
|
|
22
22
|
"homepage": "https://github.com/superdev-ai/superdev",
|
|
23
23
|
"repository": "https://github.com/superdev-ai/superdev",
|
|
24
24
|
"requires": {
|
|
25
|
-
"cli": "0.
|
|
25
|
+
"cli": "0.3.0"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/package.json
CHANGED
package/src/cli/render.mjs
CHANGED
|
@@ -317,6 +317,10 @@ export function renderTaskDetail(detail) {
|
|
|
317
317
|
["Priority", status(task.priority ?? "normal")],
|
|
318
318
|
["Feature", feature ? `${feature.id} ${feature.name}` : task.feature_id],
|
|
319
319
|
["Parent", task.parent_task_id],
|
|
320
|
+
// A superseded task that does not say what replaced it leaves anybody who
|
|
321
|
+
// found the old identifier, in a commit message or a branch name, with
|
|
322
|
+
// nowhere to go.
|
|
323
|
+
["Merged into", task.superseded_by],
|
|
320
324
|
["Estimate", task.estimate],
|
|
321
325
|
["Due", task.due_at ? shortDate(task.due_at) : null],
|
|
322
326
|
["Blocked because", task.block_reason],
|
package/src/cli.mjs
CHANGED
|
@@ -142,6 +142,7 @@ Working
|
|
|
142
142
|
task block <id> Record why a task cannot move
|
|
143
143
|
task unblock <id> Put a blocked task back where it was
|
|
144
144
|
task reopen <id> Reopen finished work, with a reason
|
|
145
|
+
task merge <id> Fold a duplicate into the task that keeps the work
|
|
145
146
|
derive [feature] Turn accepted specifications into tasks
|
|
146
147
|
|
|
147
148
|
Knowledge
|
|
@@ -242,6 +243,10 @@ Flags worth knowing
|
|
|
242
243
|
feature specify --not <text>
|
|
243
244
|
What the feature deliberately does not do. Repeatable.
|
|
244
245
|
Its counterpart is --in, and --out stays global
|
|
246
|
+
ui | start --port <number>
|
|
247
|
+
Serve on this port instead of 4317. Every project defaults
|
|
248
|
+
to the same port, so the second one to start is refused
|
|
249
|
+
and needs this
|
|
245
250
|
|
|
246
251
|
Exit codes
|
|
247
252
|
0 it worked, 1 something was found or refused, 2 the command was misused`;
|
|
@@ -757,6 +762,23 @@ const service = () => import("./service/manage.mjs");
|
|
|
757
762
|
// later rename of one field cannot silently make `ui` claim nothing is running.
|
|
758
763
|
const isRunning = (report) => report?.state === "running" || report?.running === true;
|
|
759
764
|
|
|
765
|
+
/**
|
|
766
|
+
* The port to serve on, when the default one is taken.
|
|
767
|
+
*
|
|
768
|
+
* The refusal for a held port told the reader to start on another port and no
|
|
769
|
+
* command could. This is that flag, validated here rather than in two places:
|
|
770
|
+
* `ui` and `start` are the same decision made twice, and a port that is not a
|
|
771
|
+
* usable number has to be refused before a process is spawned against it.
|
|
772
|
+
*/
|
|
773
|
+
function portFrom(ctx) {
|
|
774
|
+
if (ctx.flags.port === undefined) return null;
|
|
775
|
+
const value = Number(ctx.flags.port);
|
|
776
|
+
if (!Number.isInteger(value) || value < 1024 || value > 65535) {
|
|
777
|
+
throw new UsageError(`--port takes a whole number between 1024 and 65535. ${JSON.stringify(String(ctx.flags.port))} is not one.`);
|
|
778
|
+
}
|
|
779
|
+
return value;
|
|
780
|
+
}
|
|
781
|
+
|
|
760
782
|
async function cmdUi(ctx) {
|
|
761
783
|
const { serviceStatus, startService } = await service();
|
|
762
784
|
const current = await serviceStatus(ctx.root);
|
|
@@ -769,21 +791,25 @@ async function cmdUi(ctx) {
|
|
|
769
791
|
]),
|
|
770
792
|
};
|
|
771
793
|
}
|
|
794
|
+
const port = portFrom(ctx);
|
|
772
795
|
if (!ctx.apply) {
|
|
773
796
|
return planned(current, "start the control center",
|
|
774
|
-
R.wrap(
|
|
797
|
+
R.wrap(`The control center is not running. Starting it opens one local process for this project and listens on the loopback address only${port ? `, on port ${port}` : ""}.`));
|
|
775
798
|
}
|
|
776
|
-
const started = await startService(ctx.root, { actor: ctx.actor });
|
|
799
|
+
const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
|
|
777
800
|
return { data: { applied: true, service: started }, text: report(ctx, "Control center started", started) };
|
|
778
801
|
}
|
|
779
802
|
|
|
780
803
|
async function cmdStart(ctx) {
|
|
781
804
|
const { startService, serviceStatus } = await service();
|
|
805
|
+
const port = portFrom(ctx);
|
|
782
806
|
if (!ctx.apply) {
|
|
783
807
|
return planned(await serviceStatus(ctx.root), "start the local service",
|
|
784
|
-
|
|
808
|
+
port
|
|
809
|
+
? `Starting the service opens one local process for this project, on port ${port}.`
|
|
810
|
+
: "Starting the service opens one local process for this project.");
|
|
785
811
|
}
|
|
786
|
-
const started = await startService(ctx.root, { actor: ctx.actor });
|
|
812
|
+
const started = await startService(ctx.root, { actor: ctx.actor, ...(port ? { port } : {}) });
|
|
787
813
|
return { data: { applied: true, service: started }, text: report(ctx, "Service started", started) };
|
|
788
814
|
}
|
|
789
815
|
|
|
@@ -1213,7 +1239,13 @@ async function cmdTaskUnblock(ctx) {
|
|
|
1213
1239
|
}
|
|
1214
1240
|
const { unblockTask } = await lifecycle();
|
|
1215
1241
|
const task = await unblockTask(ctx.root, id, { actor: ctx.actor, to, note: ctx.flags.note ?? null });
|
|
1216
|
-
return {
|
|
1242
|
+
return {
|
|
1243
|
+
data: { applied: true, task },
|
|
1244
|
+
text: R.stitch([
|
|
1245
|
+
`${task.id} is ${R.status(task.status)} again.`,
|
|
1246
|
+
task.unclaimed ? R.wrap(task.unclaimed) : null,
|
|
1247
|
+
]),
|
|
1248
|
+
};
|
|
1217
1249
|
}
|
|
1218
1250
|
|
|
1219
1251
|
/**
|
|
@@ -2166,6 +2198,61 @@ async function cmdTaskBlock(ctx) {
|
|
|
2166
2198
|
return { data: { applied: true, task }, text: `${task.id} is Blocked. The reason is on the record.` };
|
|
2167
2199
|
}
|
|
2168
2200
|
|
|
2201
|
+
/**
|
|
2202
|
+
* Fold a duplicate task into the one that keeps the work.
|
|
2203
|
+
*
|
|
2204
|
+
* There is no delete. A task carries why it existed and what proved it, and
|
|
2205
|
+
* history here is append only, so a deleted task would take its evidence with it
|
|
2206
|
+
* and leave a commit message pointing at an identifier nobody can look up. The
|
|
2207
|
+
* duplicate is superseded and says which task replaced it, which is the same
|
|
2208
|
+
* answer for the reader and a better one for the record.
|
|
2209
|
+
*/
|
|
2210
|
+
async function cmdTaskMerge(ctx) {
|
|
2211
|
+
const duplicate = requireWord(ctx.words, 2, "Say which task is the duplicate: superdev task merge <duplicate-id> --into <TASK-id>.");
|
|
2212
|
+
const survivor = String(requireFlag(ctx.flags, "into", "Say which task keeps the work: --into <TASK-id>."));
|
|
2213
|
+
const { planMerge, mergeTasks } = await import("./tasks/merge.mjs");
|
|
2214
|
+
|
|
2215
|
+
if (!ctx.apply) {
|
|
2216
|
+
const plan = await planMerge(ctx.root, duplicate, survivor);
|
|
2217
|
+
const rows = Object.entries(plan.moving)
|
|
2218
|
+
.filter(([, n]) => n > 0)
|
|
2219
|
+
.map(([kind, n]) => [MERGE_LABEL[kind] ?? kind, String(n)]);
|
|
2220
|
+
return planned(plan, "merge them", R.stitch([
|
|
2221
|
+
R.wrap(`Would fold ${plan.duplicate.id} ${plan.duplicate.name} into ${plan.survivor.id} ${plan.survivor.name}.`),
|
|
2222
|
+
rows.length ? R.block("What would move", R.table(["What", "How many"], rows)) : R.wrap("Nothing is recorded against it yet, so only its status would change."),
|
|
2223
|
+
plan.releasing ? R.wrap("Its claim would be released rather than moved, because an assignment names who took the work.") : null,
|
|
2224
|
+
plan.staying.history
|
|
2225
|
+
? R.wrap(`${countWord(plan.staying.history, "activity event")} would stay on ${plan.duplicate.id}, because history records a moment and cannot be moved to another record without saying something untrue about the past.`)
|
|
2226
|
+
: null,
|
|
2227
|
+
R.wrap(`${plan.duplicate.id} would become Superseded and point at ${plan.survivor.id}. Nothing is deleted.`),
|
|
2228
|
+
]));
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const out = await mergeTasks(ctx.root, duplicate, survivor, {
|
|
2232
|
+
actor: ctx.actor, reason: ctx.flags.reason ? String(ctx.flags.reason) : null,
|
|
2233
|
+
});
|
|
2234
|
+
const rows = Object.entries(out.moved).filter(([, n]) => n > 0);
|
|
2235
|
+
return {
|
|
2236
|
+
data: out,
|
|
2237
|
+
text: R.stitch([
|
|
2238
|
+
`${out.duplicate.id} is merged into ${out.survivor.id}.`,
|
|
2239
|
+
rows.length
|
|
2240
|
+
? R.block("Moved", R.table(["What", "How many"], rows.map(([k, n]) => [MERGE_LABEL[k] ?? k, String(n)])))
|
|
2241
|
+
: null,
|
|
2242
|
+
R.wrap(`${out.duplicate.id} is ${R.status(out.duplicate.status)} and points at ${out.survivor.id}, so anyone who finds the old identifier is told where the work went.`),
|
|
2243
|
+
]),
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
const MERGE_LABEL = {
|
|
2248
|
+
evidence: "Evidence",
|
|
2249
|
+
contractLinks: "Contract links (copied, not moved)",
|
|
2250
|
+
dependencies: "Dependencies",
|
|
2251
|
+
memories: "Memory entries",
|
|
2252
|
+
changes: "Recorded changes",
|
|
2253
|
+
children: "Child tasks",
|
|
2254
|
+
};
|
|
2255
|
+
|
|
2169
2256
|
async function cmdTaskReopen(ctx) {
|
|
2170
2257
|
const id = requireWord(ctx.words, 2, "Say which task to reopen: superdev task reopen <id>.");
|
|
2171
2258
|
const reason = requireFlag(ctx.flags, "reason", "Reopening finished work needs a reason, because the record already said it was done.");
|
|
@@ -2176,7 +2263,15 @@ async function cmdTaskReopen(ctx) {
|
|
|
2176
2263
|
}
|
|
2177
2264
|
const { reopenTask } = await lifecycle();
|
|
2178
2265
|
const task = await reopenTask(ctx.root, id, { reason, to, actor: ctx.actor });
|
|
2179
|
-
return {
|
|
2266
|
+
return {
|
|
2267
|
+
data: { applied: true, task },
|
|
2268
|
+
// A task nobody holds lands Ready, and the reason is said rather than left
|
|
2269
|
+
// for the reader to notice by comparing this against what they expected.
|
|
2270
|
+
text: R.stitch([
|
|
2271
|
+
`${task.id} is open again and is ${R.status(task.status)}.`,
|
|
2272
|
+
task.unclaimed ? R.wrap(task.unclaimed) : null,
|
|
2273
|
+
]),
|
|
2274
|
+
};
|
|
2180
2275
|
}
|
|
2181
2276
|
|
|
2182
2277
|
// ---------------------------------------------------------------------- derive
|
|
@@ -3069,6 +3164,7 @@ const COMMANDS = {
|
|
|
3069
3164
|
"task cancel": cmdTaskCancel,
|
|
3070
3165
|
"task complete": cmdTaskComplete,
|
|
3071
3166
|
"task block": cmdTaskBlock,
|
|
3167
|
+
"task merge": cmdTaskMerge,
|
|
3072
3168
|
"task reopen": cmdTaskReopen,
|
|
3073
3169
|
"docs generate": cmdDocsGenerate,
|
|
3074
3170
|
"docs diff": cmdDocsDiff,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Which task replaced this one.
|
|
2
|
+
--
|
|
3
|
+
-- Two records for the same work split its evidence and its progress: each looks
|
|
4
|
+
-- half finished, the feature they serve can never show either as done, and nothing
|
|
5
|
+
-- says they are the same work. Deleting one is not the answer, because a task
|
|
6
|
+
-- carries the reasons it existed and the evidence recorded against it, and history
|
|
7
|
+
-- here is append only by design.
|
|
8
|
+
--
|
|
9
|
+
-- So a duplicate is superseded, and this is where it says by what. Without the
|
|
10
|
+
-- column the relationship could only live in prose in an activity event, which
|
|
11
|
+
-- nothing can follow: somebody who finds the old identifier in a commit message or
|
|
12
|
+
-- a branch name has to be told where the work went.
|
|
13
|
+
--
|
|
14
|
+
-- memory_entries has carried a superseded_by since the first migration for exactly
|
|
15
|
+
-- this reason. Tasks needed the same thing and did not have it.
|
|
16
|
+
|
|
17
|
+
ALTER TABLE tasks ADD COLUMN superseded_by TEXT REFERENCES tasks(id);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX tasks_superseded_by ON tasks(superseded_by) WHERE superseded_by IS NOT NULL;
|
package/src/docs/render.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { dirname, join, posix } from "node:path";
|
|
|
22
22
|
import { query, mutate, create, recordActivity, currentProject, json } from "../db/store.mjs";
|
|
23
23
|
import { assertStorable } from "../model/screening.mjs";
|
|
24
24
|
import { slugify } from "../model/ids.mjs";
|
|
25
|
-
import { EDGE_CASE_CATEGORIES, MODULE_STEPS } from "../model/vocabulary.mjs";
|
|
25
|
+
import { EDGE_CASE_CATEGORIES, MODULE_STEPS, AMBIENT_EVENTS } from "../model/vocabulary.mjs";
|
|
26
26
|
import * as T from "./templates.mjs";
|
|
27
27
|
|
|
28
28
|
export const MARKER = "superdev:generated";
|
|
@@ -145,8 +145,9 @@ async function snapshot(db, projectId) {
|
|
|
145
145
|
db,
|
|
146
146
|
`SELECT sequence, event_type, summary, actor_label, created_at FROM activity_events
|
|
147
147
|
WHERE project_id = ? AND event_type NOT LIKE 'documentation_%'
|
|
148
|
+
AND event_type NOT IN (${AMBIENT_EVENTS.map(() => "?").join(", ")})
|
|
148
149
|
ORDER BY sequence DESC LIMIT 200`,
|
|
149
|
-
projectId,
|
|
150
|
+
projectId, ...AMBIENT_EVENTS,
|
|
150
151
|
);
|
|
151
152
|
|
|
152
153
|
index(s);
|
package/src/docs/templates.mjs
CHANGED
|
@@ -1173,7 +1173,7 @@ export function changelog(data) {
|
|
|
1173
1173
|
return doc(
|
|
1174
1174
|
REGENERATED,
|
|
1175
1175
|
`# ${text(data.project.name, "Project")} - Changelog`,
|
|
1176
|
-
"Specification and
|
|
1176
|
+
"Specification, decision and task changes, newest first, taken from the append-only activity log. File and session traffic stays in the control center, because a note that files moved is not a change to anything described here.",
|
|
1177
1177
|
table(
|
|
1178
1178
|
["#", "Date", "Change", "Actor"],
|
|
1179
1179
|
data.entries.map((e) => [e.sequence, e.date, e.summary, e.actor]),
|
package/src/model/vocabulary.mjs
CHANGED
|
@@ -13,6 +13,32 @@ export const OPEN_TASK_STATUSES = TASK_STATUSES.filter(
|
|
|
13
13
|
|
|
14
14
|
export const TERMINAL_TASK_STATUSES = ["complete", "cancelled", "superseded"];
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Events that describe the working tree or a session, rather than a record.
|
|
18
|
+
*
|
|
19
|
+
* Generated documents are a projection of the database, so what makes one stale is
|
|
20
|
+
* a change to a record it renders. A `code_changed` event is a note that files
|
|
21
|
+
* moved; it changes nothing any document projects. Freshness counted it anyway, so
|
|
22
|
+
* every commit marked all 303 documents stale, and since the release gate runs
|
|
23
|
+
* doctor after the commit, the gate could never pass: regenerating cleared it, the
|
|
24
|
+
* next commit brought it back.
|
|
25
|
+
*
|
|
26
|
+
* The changelog had the matching half of the same confusion. It renders every event
|
|
27
|
+
* that is not a documentation event, while its own description says "Task and
|
|
28
|
+
* session traffic stays in the control center". The description was right about
|
|
29
|
+
* what belongs there and the query did not implement it.
|
|
30
|
+
*
|
|
31
|
+
* Held here because two modules have to agree about it: the one that decides what a
|
|
32
|
+
* document renders, and the one that decides when a document is out of date.
|
|
33
|
+
*/
|
|
34
|
+
export const AMBIENT_EVENTS = [
|
|
35
|
+
"code_changed",
|
|
36
|
+
"untracked_work",
|
|
37
|
+
"session_started",
|
|
38
|
+
"session_ended",
|
|
39
|
+
"session_compacted",
|
|
40
|
+
];
|
|
41
|
+
|
|
16
42
|
/** Human-facing label. Title Case, plain language, no internal vocabulary. */
|
|
17
43
|
export const LABEL = {
|
|
18
44
|
draft: "Draft",
|
package/src/progress/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// project root, so a caller composes several of these inside one read.
|
|
16
16
|
|
|
17
17
|
import { json, DbError } from "../db/store.mjs";
|
|
18
|
-
import { agrees, count } from "../model/vocabulary.mjs";
|
|
18
|
+
import { agrees, count, AMBIENT_EVENTS } from "../model/vocabulary.mjs";
|
|
19
19
|
|
|
20
20
|
const DAY_MS = 86_400_000;
|
|
21
21
|
|
|
@@ -715,12 +715,20 @@ export async function freshness(db, projectId) {
|
|
|
715
715
|
// it rendered at and only then records its own documentation_generated event,
|
|
716
716
|
// so comparing against the raw maximum sequence would report every document as
|
|
717
717
|
// stale the instant it was produced, and no run could ever clear it.
|
|
718
|
+
// Documentation events are excluded because a render is not a content change,
|
|
719
|
+
// and ambient events because a note that files moved or a session started
|
|
720
|
+
// changes no record any document projects. Counting those made every commit
|
|
721
|
+
// mark all of them stale, which the release gate then refused, and regenerating
|
|
722
|
+
// only cleared it until the next commit.
|
|
723
|
+
const IGNORED = [
|
|
724
|
+
"documentation_generated", "documentation_proposal_raised",
|
|
725
|
+
"documentation_proposal_resolved", "documentation_possibly_stale",
|
|
726
|
+
...AMBIENT_EVENTS,
|
|
727
|
+
];
|
|
718
728
|
const contentRevision = (await db.get(
|
|
719
729
|
`SELECT max(sequence) AS s FROM activity_events
|
|
720
|
-
WHERE project_id = ? AND event_type NOT IN
|
|
721
|
-
|
|
722
|
-
'documentation_proposal_resolved','documentation_possibly_stale')`,
|
|
723
|
-
projectId,
|
|
730
|
+
WHERE project_id = ? AND event_type NOT IN (${IGNORED.map(() => "?").join(", ")})`,
|
|
731
|
+
projectId, ...IGNORED,
|
|
724
732
|
))?.s ?? 0;
|
|
725
733
|
const live = documents.filter((d) => d.sync_status !== "retired");
|
|
726
734
|
const behind = live.filter((d) => (d.database_revision ?? 0) < contentRevision);
|
package/src/runtime/hooks.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Secrets never reach the output: everything user facing goes through
|
|
23
23
|
// sanitizeExternal, which strips secret-shaped values and home paths.
|
|
24
24
|
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, writeSync, realpathSync } from "node:fs";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, writeSync, realpathSync } from "node:fs";
|
|
26
26
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
27
|
import { paths, query } from "../db/store.mjs";
|
|
28
28
|
import { availableMigrations } from "../db/migrate.mjs";
|
|
@@ -472,11 +472,28 @@ async function postToolUse(root, payload) {
|
|
|
472
472
|
if (!MUTATING_TOOLS.has(tool) && !viaShell) return EMPTY;
|
|
473
473
|
|
|
474
474
|
let file = payload?.tool_input?.file_path ?? payload?.tool_input?.notebook_path ?? payload?.tool_input?.path;
|
|
475
|
+
// Whether anything is newly changed, decided before the paths are marked.
|
|
476
|
+
//
|
|
477
|
+
// git reports a file as changed until it is committed, so a shell command run
|
|
478
|
+
// after a task is completed re-reported the very files that task had produced,
|
|
479
|
+
// and the readiness report raised its only high-severity warning about work
|
|
480
|
+
// that had just been finished, evidenced and recorded. Any redirect or heredoc
|
|
481
|
+
// is enough to make the hook look, so this happened on the first command after
|
|
482
|
+
// every completion.
|
|
483
|
+
//
|
|
484
|
+
// The paths already seen are the answer. Work noticed twice is one piece of
|
|
485
|
+
// work; a path not seen before is a genuine change. Computed here because
|
|
486
|
+
// markTouched below would otherwise record these paths and make every one of
|
|
487
|
+
// them look familiar.
|
|
488
|
+
let fresh = true;
|
|
475
489
|
if (viaShell) {
|
|
476
490
|
const changed = await changedFiles(root);
|
|
477
491
|
// A command that looked like a write and changed nothing is not work.
|
|
478
492
|
if (!changed.length) return EMPTY;
|
|
493
|
+
const moved = movedSince(root, changed, readTouched(root).attributed);
|
|
494
|
+
fresh = moved.length > 0;
|
|
479
495
|
markTouched(root, changed);
|
|
496
|
+
attribute(root, moved);
|
|
480
497
|
file = file ?? changed[0];
|
|
481
498
|
}
|
|
482
499
|
markTouched(root, [file]);
|
|
@@ -487,7 +504,7 @@ async function postToolUse(root, payload) {
|
|
|
487
504
|
// files touched by the active task; when there is no active task, that
|
|
488
505
|
// absence is the fact worth keeping. Rate limited so a long editing run
|
|
489
506
|
// leaves one honest marker rather than one per keystroke.
|
|
490
|
-
await noteUntrackedWork(root, file).catch(() => {});
|
|
507
|
+
if (fresh) await noteUntrackedWork(root, file).catch(() => {});
|
|
491
508
|
await markDocsPossiblyStale(root, file).catch(() => {});
|
|
492
509
|
return EMPTY;
|
|
493
510
|
}
|
|
@@ -521,9 +538,12 @@ function readTouched(root) {
|
|
|
521
538
|
total: Number.isFinite(parsed.total) ? parsed.total : 0,
|
|
522
539
|
since: typeof parsed.since === "string" ? parsed.since : null,
|
|
523
540
|
lastEventAt: typeof parsed.lastEventAt === "string" ? parsed.lastEventAt : null,
|
|
541
|
+
// When each path was last accounted for, which `paths` cannot answer
|
|
542
|
+
// because a flush empties it.
|
|
543
|
+
attributed: parsed.attributed && typeof parsed.attributed === "object" ? parsed.attributed : {},
|
|
524
544
|
};
|
|
525
545
|
} catch {
|
|
526
|
-
return { paths: [], total: 0, since: null, lastEventAt: null };
|
|
546
|
+
return { paths: [], total: 0, since: null, lastEventAt: null, attributed: {} };
|
|
527
547
|
}
|
|
528
548
|
}
|
|
529
549
|
|
|
@@ -537,6 +557,61 @@ function writeTouched(root, state) {
|
|
|
537
557
|
}
|
|
538
558
|
}
|
|
539
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Whether a path is the product, rather than Superdev's own bookkeeping about it.
|
|
562
|
+
*
|
|
563
|
+
* markTouched has always skipped `.superdev/`, and the freshness comparison did
|
|
564
|
+
* not, so the runtime directory was picking up modification stamps. One of those
|
|
565
|
+
* files is the database write-ahead log, which moves on every single command, so
|
|
566
|
+
* every command would have looked like new work and the comparison would have
|
|
567
|
+
* decided nothing. Both callers now ask the same question in the same place.
|
|
568
|
+
*/
|
|
569
|
+
const isProjectWork = (rel) => Boolean(rel) && !rel.startsWith(".superdev/");
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Which of these paths have actually moved since they were last accounted for.
|
|
573
|
+
*
|
|
574
|
+
* git reports a file as changed until it is committed, so every shell command run
|
|
575
|
+
* between an edit and its commit re-reports the same files. That made the hook
|
|
576
|
+
* record fresh untracked work for edits a completed task had already produced and
|
|
577
|
+
* evidenced, and the readiness report then raised its only high-severity warning
|
|
578
|
+
* about work that was finished. Any redirect or heredoc is enough to make the hook
|
|
579
|
+
* look, so it happened on the first command after every completion.
|
|
580
|
+
*
|
|
581
|
+
* The pending path list cannot answer this, because a flush empties it. A
|
|
582
|
+
* modification time can: a path re-reported without moving is the same work seen
|
|
583
|
+
* again, and a path whose file has changed since is new work whoever is holding
|
|
584
|
+
* the task. Missing stats count as moved, because failing to notice real work is
|
|
585
|
+
* the worse mistake of the two.
|
|
586
|
+
*/
|
|
587
|
+
export function movedSince(root, candidates, attributed) {
|
|
588
|
+
candidates = candidates.filter(isProjectWork);
|
|
589
|
+
const moved = [];
|
|
590
|
+
for (const path of candidates) {
|
|
591
|
+
let stamp = null;
|
|
592
|
+
try {
|
|
593
|
+
stamp = statSync(join(root, path)).mtimeMs;
|
|
594
|
+
} catch {
|
|
595
|
+
// Deleted, or unreadable. Either way it is not the file we accounted for.
|
|
596
|
+
moved.push([path, Date.now()]);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (stamp > (Number(attributed[path]) || 0)) moved.push([path, stamp]);
|
|
600
|
+
}
|
|
601
|
+
return moved;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Record that these paths are accounted for, at the state they are in now. */
|
|
605
|
+
function attribute(root, moved) {
|
|
606
|
+
if (!moved.length) return;
|
|
607
|
+
const state = readTouched(root);
|
|
608
|
+
const next = { ...state.attributed };
|
|
609
|
+
for (const [path, stamp] of moved) next[path] = stamp;
|
|
610
|
+
// Bounded, oldest first, so a long-lived project does not grow this forever.
|
|
611
|
+
const entries = Object.entries(next).sort((a, b) => b[1] - a[1]).slice(0, MAX_TOUCHED);
|
|
612
|
+
writeTouched(root, { ...state, attributed: Object.fromEntries(entries) });
|
|
613
|
+
}
|
|
614
|
+
|
|
540
615
|
/**
|
|
541
616
|
* Record which project-relative paths changed.
|
|
542
617
|
*
|
|
@@ -550,7 +625,7 @@ export function markTouched(root, files) {
|
|
|
550
625
|
if (!file) continue;
|
|
551
626
|
const rel = relative(root, canonical(root, file)).split(sep).join("/");
|
|
552
627
|
if (!rel || rel.startsWith("../") || isAbsolute(rel)) continue;
|
|
553
|
-
if (rel
|
|
628
|
+
if (!isProjectWork(rel)) continue;
|
|
554
629
|
kept.push(rel);
|
|
555
630
|
}
|
|
556
631
|
if (!kept.length) return null;
|
|
@@ -562,6 +637,7 @@ export function markTouched(root, files) {
|
|
|
562
637
|
total: state.total + kept.length,
|
|
563
638
|
since: state.since ?? new Date().toISOString(),
|
|
564
639
|
lastEventAt: state.lastEventAt,
|
|
640
|
+
attributed: state.attributed,
|
|
565
641
|
};
|
|
566
642
|
writeTouched(root, next);
|
|
567
643
|
return next;
|
|
@@ -633,7 +709,10 @@ export async function flushTouched(root, { force = false } = {}) {
|
|
|
633
709
|
metadata: { files: state.paths.length, changes: state.total },
|
|
634
710
|
}).catch((err) => ({ recorded: false, reason: message(err) }));
|
|
635
711
|
|
|
636
|
-
writeTouched(root, {
|
|
712
|
+
writeTouched(root, {
|
|
713
|
+
paths: [], total: 0, since: null, lastEventAt: new Date().toISOString(),
|
|
714
|
+
attributed: state.attributed,
|
|
715
|
+
});
|
|
637
716
|
return result;
|
|
638
717
|
}
|
|
639
718
|
|
package/src/service/manage.mjs
CHANGED
|
@@ -135,6 +135,19 @@ export function probeHealth(host, port, timeout = PROBE_TIMEOUT_MS) {
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A path a reader can retype, shortened at home so a long absolute path does not
|
|
140
|
+
* bury the part that identifies the project.
|
|
141
|
+
*
|
|
142
|
+
* Every message about a port used to describe it without saying whose it was,
|
|
143
|
+
* even though the health probe had already been handed the answer, so finding out
|
|
144
|
+
* meant opening candidate directories one at a time.
|
|
145
|
+
*/
|
|
146
|
+
export function displayRoot(path) {
|
|
147
|
+
const home = homedir();
|
|
148
|
+
return home && path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
/**
|
|
139
152
|
* What is actually true for this project right now. Distinguishes a service we
|
|
140
153
|
* manage from a stale lock, from a start in progress, from someone else holding
|
|
@@ -160,14 +173,31 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
|
|
|
160
173
|
};
|
|
161
174
|
}
|
|
162
175
|
if (health) {
|
|
176
|
+
// Not running, and the default port happens to be busy. Those are two
|
|
177
|
+
// separate facts and only the first one is about this project.
|
|
178
|
+
//
|
|
179
|
+
// This used to report `foreign`, which made both commands lie. `start`
|
|
180
|
+
// never blocked on it: the server scans forward from the requested port on
|
|
181
|
+
// EADDRINUSE, so a second project quietly lands on the next free one, and
|
|
182
|
+
// the message told the reader to do something the tool was already doing.
|
|
183
|
+
// `stop` did worse. On a project with nothing running it refused, and told
|
|
184
|
+
// the reader to go and stop a different project, which they have no reason
|
|
185
|
+
// to touch.
|
|
186
|
+
//
|
|
187
|
+
// Reserving `foreign` for the case that is genuinely somebody else's
|
|
188
|
+
// service in our place is what makes the word mean something.
|
|
163
189
|
return {
|
|
164
|
-
state: "
|
|
190
|
+
state: "stopped",
|
|
165
191
|
managed: false,
|
|
166
192
|
lock: null,
|
|
167
193
|
health,
|
|
168
|
-
port:
|
|
194
|
+
port: null,
|
|
169
195
|
host,
|
|
170
|
-
|
|
196
|
+
portInUse: candidate,
|
|
197
|
+
heldBy: health.projectRoot ?? null,
|
|
198
|
+
explanation: `Nothing is running for this project. Port ${candidate} is held by ${
|
|
199
|
+
health.projectRoot ? `the Superdev service for ${displayRoot(health.projectRoot)}` : "another Superdev service"
|
|
200
|
+
}, so starting this one will use the next free port instead. Pass --port <number> to choose.`,
|
|
171
201
|
};
|
|
172
202
|
}
|
|
173
203
|
return {
|
|
@@ -217,7 +247,9 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
|
|
|
217
247
|
health,
|
|
218
248
|
port: lock.port,
|
|
219
249
|
host: lock.host ?? host,
|
|
220
|
-
explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes
|
|
250
|
+
explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes${
|
|
251
|
+
health.projectRoot ? `: it belongs to ${displayRoot(health.projectRoot)}` : ""
|
|
252
|
+
}. This project's service is gone and something else took its port. Run \`superdev start --apply\` to bring this one back on a free port.`,
|
|
221
253
|
};
|
|
222
254
|
}
|
|
223
255
|
return {
|
|
@@ -335,7 +367,12 @@ export async function startService(root, options = {}) {
|
|
|
335
367
|
);
|
|
336
368
|
if (!held) {
|
|
337
369
|
if (view.state === "foreign") {
|
|
338
|
-
throw new ServiceError(E.FOREIGN, view.explanation, {
|
|
370
|
+
throw new ServiceError(E.FOREIGN, view.explanation, {
|
|
371
|
+
port: view.port,
|
|
372
|
+
// The caller cannot act on a refusal whose subject it cannot see, and the
|
|
373
|
+
// probe already knows it. `superdev services` prints it; a script reads it.
|
|
374
|
+
heldBy: view.health?.projectRoot ?? null,
|
|
375
|
+
});
|
|
339
376
|
}
|
|
340
377
|
return { ...summarize(root, view), alreadyRunning: true };
|
|
341
378
|
}
|
package/src/tasks/lifecycle.mjs
CHANGED
|
@@ -295,19 +295,10 @@ export async function claimTask(root, taskId, who = {}) {
|
|
|
295
295
|
// out to git, and subprocess work inside a write transaction holds the
|
|
296
296
|
// exclusive lock across it.
|
|
297
297
|
if (!developerId && !agentId) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
displayName: who.displayName ?? (actor !== "superdev" ? actor : undefined),
|
|
303
|
-
});
|
|
304
|
-
developerId = developerId ?? identity.developer?.id ?? null;
|
|
305
|
-
agentId = agentId ?? identity.agent?.id ?? null;
|
|
306
|
-
branchId = branchId ?? identity.branch?.id ?? null;
|
|
307
|
-
} catch {
|
|
308
|
-
// An unresolvable identity must not block the claim: the task still gets
|
|
309
|
-
// held, and the assignment simply says so rather than naming someone.
|
|
310
|
-
}
|
|
298
|
+
const identity = await whoIsActing(root, { actor, displayName: who.displayName });
|
|
299
|
+
developerId = developerId ?? identity.developerId;
|
|
300
|
+
agentId = agentId ?? identity.agentId;
|
|
301
|
+
branchId = branchId ?? identity.branchId;
|
|
311
302
|
}
|
|
312
303
|
|
|
313
304
|
return mutate(root, async (db) => {
|
|
@@ -361,21 +352,11 @@ export async function claimTask(root, taskId, who = {}) {
|
|
|
361
352
|
|
|
362
353
|
// A lease is a statement to somebody else. With no peer configured there is
|
|
363
354
|
// nobody to hear it, and the local unique index is already the whole answer.
|
|
364
|
-
const peer = await db.get("SELECT alias FROM sync_peers WHERE status = 'connected' LIMIT 1")
|
|
365
|
-
.catch(() => null);
|
|
366
355
|
try {
|
|
367
|
-
await
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
branch_id: branchId,
|
|
372
|
-
session_id: sessionId,
|
|
373
|
-
assigned_at: nowIso(),
|
|
374
|
-
active: 1,
|
|
375
|
-
lease_holder: peer?.alias ?? null,
|
|
376
|
-
lease_expires_at: peer ? leaseExpiry(nowIso()) : null,
|
|
377
|
-
}, { projectId: task.project_id, actor, sessionId, taskId, activityType: "task_claimed",
|
|
378
|
-
activitySummary: `${taskId} claimed.` });
|
|
356
|
+
await assign(db, task, {
|
|
357
|
+
developerId, agentId, branchId, sessionId, actor,
|
|
358
|
+
summary: `${taskId} claimed.`,
|
|
359
|
+
});
|
|
379
360
|
} catch (err) {
|
|
380
361
|
// Only a uniqueness failure means someone else got the claim first. The
|
|
381
362
|
// test used to accept any message containing "constraint", so a foreign
|
|
@@ -403,8 +384,6 @@ export async function claimTask(root, taskId, who = {}) {
|
|
|
403
384
|
//
|
|
404
385
|
// Written here rather than in each caller, because the control centre claims
|
|
405
386
|
// tasks too and the field has to mean the same thing whichever surface moved it.
|
|
406
|
-
await pointSessionAt(db, task.project_id, sessionId, taskId);
|
|
407
|
-
|
|
408
387
|
return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
|
|
409
388
|
});
|
|
410
389
|
}
|
|
@@ -489,6 +468,7 @@ export async function blockTask(root, taskId, { reason, actor = "superdev", sess
|
|
|
489
468
|
|
|
490
469
|
/** Return to whatever the task was doing before it blocked, not to a guess. */
|
|
491
470
|
export async function unblockTask(root, taskId, { actor = "superdev", note = null, to = null } = {}) {
|
|
471
|
+
const identity = await whoIsActing(root, { actor });
|
|
492
472
|
return mutate(root, async (db) => {
|
|
493
473
|
const task = await taskOr404(db, taskId);
|
|
494
474
|
if (task.status !== "blocked") {
|
|
@@ -500,8 +480,10 @@ export async function unblockTask(root, taskId, { actor = "superdev", note = nul
|
|
|
500
480
|
ORDER BY sequence DESC LIMIT 1`,
|
|
501
481
|
taskId,
|
|
502
482
|
);
|
|
503
|
-
const
|
|
504
|
-
|
|
483
|
+
const wanted = to ?? (ACTIVE.has(previous?.from_status) ? previous.from_status : "ready");
|
|
484
|
+
const owned = await ownedTarget(db, task, wanted, { actor, identity });
|
|
485
|
+
const moved = await move(db, task, owned.target, { actor, note, activityType: "task_unblocked" });
|
|
486
|
+
return owned.note ? { ...moved, unclaimed: owned.note } : moved;
|
|
505
487
|
});
|
|
506
488
|
}
|
|
507
489
|
|
|
@@ -691,20 +673,125 @@ export async function completeTask(root, taskId, { actor = "superdev", sessionId
|
|
|
691
673
|
}
|
|
692
674
|
|
|
693
675
|
/** Reopening is a recorded decision, never a quiet edit of a finished record. */
|
|
676
|
+
/**
|
|
677
|
+
* Who is acting, resolved outside every transaction.
|
|
678
|
+
*
|
|
679
|
+
* resolveIdentity shells out to git, and subprocess work inside a write
|
|
680
|
+
* transaction holds the engine's exclusive lock across it, so this always runs
|
|
681
|
+
* before mutate opens. A claim recording three nulls says nothing about who holds
|
|
682
|
+
* the task, which is the only reason the claim exists.
|
|
683
|
+
*/
|
|
684
|
+
async function whoIsActing(root, { actor, displayName } = {}) {
|
|
685
|
+
try {
|
|
686
|
+
const { resolveIdentity, detectHarness } = await import("../runtime/identity.mjs");
|
|
687
|
+
const identity = await resolveIdentity(root, {
|
|
688
|
+
harness: detectHarness(process.env).harness,
|
|
689
|
+
displayName: displayName ?? (actor && actor !== "superdev" ? actor : undefined),
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
developerId: identity.developer?.id ?? null,
|
|
693
|
+
agentId: identity.agent?.id ?? null,
|
|
694
|
+
branchId: identity.branch?.id ?? null,
|
|
695
|
+
};
|
|
696
|
+
} catch {
|
|
697
|
+
// An unresolvable identity must not block the work: the claim still happens
|
|
698
|
+
// and simply says so rather than naming somebody.
|
|
699
|
+
return { developerId: null, agentId: null, branchId: null };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Create the claim, point the session at it, and say who holds it now.
|
|
705
|
+
*
|
|
706
|
+
* Factored out of claimTask so that reopening a task can take the claim through
|
|
707
|
+
* exactly the same path. Two ways of claiming would be two things to keep in
|
|
708
|
+
* agreement, and the last three defects in this area were all one fact recorded
|
|
709
|
+
* in one place and read from another.
|
|
710
|
+
*/
|
|
711
|
+
async function assign(db, task, { developerId, agentId, branchId, sessionId, actor, summary }) {
|
|
712
|
+
const peer = await db.get("SELECT alias FROM sync_peers WHERE status = 'connected' LIMIT 1")
|
|
713
|
+
.catch(() => null);
|
|
714
|
+
await create(db, "task_assignment", {
|
|
715
|
+
task_id: task.id,
|
|
716
|
+
developer_id: developerId,
|
|
717
|
+
agent_id: agentId,
|
|
718
|
+
branch_id: branchId,
|
|
719
|
+
session_id: sessionId,
|
|
720
|
+
assigned_at: nowIso(),
|
|
721
|
+
active: 1,
|
|
722
|
+
lease_holder: peer?.alias ?? null,
|
|
723
|
+
lease_expires_at: peer ? leaseExpiry(nowIso()) : null,
|
|
724
|
+
}, { projectId: task.project_id, actor, sessionId, taskId: task.id, activityType: "task_claimed",
|
|
725
|
+
activitySummary: summary });
|
|
726
|
+
await pointSessionAt(db, task.project_id, sessionId, task.id);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Statuses that assert somebody is working on the task right now.
|
|
731
|
+
*
|
|
732
|
+
* A task in one of these with no claim behind it is a state the record should not
|
|
733
|
+
* be able to reach, and it had two ways in. `task reopen` moved a completed task
|
|
734
|
+
* straight to in_progress, and `task unblock` restored whatever status the task
|
|
735
|
+
* held before it was blocked. Neither took a claim, so the session's active task
|
|
736
|
+
* stayed null while a task said it was in progress, and the untracked-work hook,
|
|
737
|
+
* which reads exactly that field, then recorded every later edit as work nobody
|
|
738
|
+
* had accounted for. Readiness reported it at high severity against work being
|
|
739
|
+
* done on a correctly specified, in-progress task.
|
|
740
|
+
*/
|
|
741
|
+
const ACTIVE = new Set(["in_progress", "in_review", "verifying"]);
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The status a transition may actually use, given who holds the task.
|
|
745
|
+
*
|
|
746
|
+
* An unclaimed task drops to ready rather than being claimed on somebody's
|
|
747
|
+
* behalf: reopening a task is a statement that the work is not finished, which is
|
|
748
|
+
* a different fact from somebody working on it this minute, and taking a claim
|
|
749
|
+
* silently would be worse than asking for one. A task the caller already holds
|
|
750
|
+
* keeps the active status, and the session is pointed at it either way.
|
|
751
|
+
*/
|
|
752
|
+
async function ownedTarget(db, task, target, { actor, identity }) {
|
|
753
|
+
if (!ACTIVE.has(target)) return { target, note: null };
|
|
754
|
+
const held = await activeAssignment(db, task.id);
|
|
755
|
+
if (held) {
|
|
756
|
+
// Somebody already holds it, including possibly another machine. Their claim
|
|
757
|
+
// stands; this only makes sure the session it belongs to points at it.
|
|
758
|
+
await pointSessionAt(db, task.project_id, held.session_id, task.id);
|
|
759
|
+
return { target, note: null };
|
|
760
|
+
}
|
|
761
|
+
// Nobody holds it. Dropping to ready is not available: the status machine only
|
|
762
|
+
// allows complete to in_progress, and loosening a transition to work around a
|
|
763
|
+
// missing claim would be the wrong repair. So the claim is taken, by the actor
|
|
764
|
+
// who asked for this, which is an explicit act that already carries a reason.
|
|
765
|
+
await assign(db, task, {
|
|
766
|
+
developerId: identity?.developerId ?? null,
|
|
767
|
+
agentId: identity?.agentId ?? null,
|
|
768
|
+
branchId: identity?.branchId ?? null,
|
|
769
|
+
sessionId: null,
|
|
770
|
+
actor,
|
|
771
|
+
summary: `${task.id} claimed by reopening it.`,
|
|
772
|
+
});
|
|
773
|
+
return { target, note: `It is claimed for ${actor}, because a task in progress that no session owns is a state the record should not be able to reach.` };
|
|
774
|
+
}
|
|
775
|
+
|
|
694
776
|
export async function reopenTask(root, taskId, { reason, actor = "superdev", to = null } = {}) {
|
|
695
777
|
if (!reason) throw new TaskError(E.REASON_REQUIRED, "Reopening a finished task needs a reason, because the record already said it was done.");
|
|
778
|
+
// Before mutate, because reopening may have to take the claim and resolving who
|
|
779
|
+
// is acting shells out to git.
|
|
780
|
+
const identity = await whoIsActing(root, { actor });
|
|
696
781
|
return mutate(root, async (db) => {
|
|
697
782
|
const task = await taskOr404(db, taskId);
|
|
698
783
|
if (OPEN(task.status)) {
|
|
699
784
|
throw new TaskError(E.INVALID_TRANSITION, `${taskId} is ${task.status} and already open. There is nothing to reopen.`);
|
|
700
785
|
}
|
|
701
|
-
const
|
|
786
|
+
const wanted = to ?? (task.status === "complete" ? "in_progress" : "ready");
|
|
787
|
+
const { target, note } = await ownedTarget(db, task, wanted, { actor, identity });
|
|
702
788
|
const cleared = await patch(db, "task", taskId, task.version, {
|
|
703
789
|
completed_at: null,
|
|
704
790
|
cancelled_at: null,
|
|
705
791
|
}, { projectId: task.project_id, actor, activityType: "task_reopened",
|
|
706
792
|
activitySummary: `${taskId} reopened: ${reason}` });
|
|
707
|
-
|
|
793
|
+
const moved = await move(db, cleared, target, { actor, note: reason, activityType: "task_reopened" });
|
|
794
|
+
return note ? { ...moved, unclaimed: note } : moved;
|
|
708
795
|
});
|
|
709
796
|
}
|
|
710
797
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Folding a duplicate task into the one that keeps the work.
|
|
2
|
+
//
|
|
3
|
+
// Deriving twice, or creating by hand what derivation had already created, leaves
|
|
4
|
+
// two records for one piece of work. Each collects some of the evidence, so each
|
|
5
|
+
// looks half finished, the feature they serve can never show either as done, and
|
|
6
|
+
// progress counts the same work twice while completing neither.
|
|
7
|
+
//
|
|
8
|
+
// Deleting one is the obvious move and the wrong one. A task carries why it
|
|
9
|
+
// existed, what it implements and what proved it, and history here is append only
|
|
10
|
+
// on purpose: a deleted task takes its evidence with it and leaves a commit
|
|
11
|
+
// message or a branch name pointing at an identifier nobody can look up. So
|
|
12
|
+
// nothing is deleted. What the duplicate owns moves to the survivor, the duplicate
|
|
13
|
+
// becomes superseded, and it says which task replaced it.
|
|
14
|
+
//
|
|
15
|
+
// What does not move, and why:
|
|
16
|
+
//
|
|
17
|
+
// Activity events stay where they happened. They are a record of a moment, and
|
|
18
|
+
// rewriting them to point elsewhere would make the log say something that is not
|
|
19
|
+
// true about the past.
|
|
20
|
+
//
|
|
21
|
+
// Claims stay with whoever made them. An assignment names a person, a machine
|
|
22
|
+
// and a branch; moving it would put somebody's name against work they did not
|
|
23
|
+
// take. The duplicate's claim is released instead.
|
|
24
|
+
|
|
25
|
+
import { mutate, query, recordActivity, setStatus } from "../db/store.mjs";
|
|
26
|
+
|
|
27
|
+
export const E = {
|
|
28
|
+
NOT_FOUND: "E_NOT_FOUND",
|
|
29
|
+
SAME_TASK: "E_SAME_TASK",
|
|
30
|
+
DIFFERENT_PROJECT: "E_DIFFERENT_PROJECT",
|
|
31
|
+
NOT_MERGEABLE: "E_NOT_MERGEABLE",
|
|
32
|
+
HELD: "E_ALREADY_CLAIMED",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class MergeError extends Error {
|
|
36
|
+
constructor(code, message, detail) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "MergeError";
|
|
39
|
+
this.code = code;
|
|
40
|
+
if (detail !== undefined) this.detail = detail;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A task in one of these has already been closed out; merging it again says nothing. */
|
|
45
|
+
const CLOSED = new Set(["superseded", "cancelled"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a merge would move, without moving any of it.
|
|
49
|
+
*
|
|
50
|
+
* Counted per kind rather than totalled, because "6 things would move" tells a
|
|
51
|
+
* reader nothing about whether they meant to move them.
|
|
52
|
+
*/
|
|
53
|
+
export async function planMerge(root, duplicateId, survivorId) {
|
|
54
|
+
return query(root, async (db) => {
|
|
55
|
+
const [duplicate, survivor] = await Promise.all([
|
|
56
|
+
db.get("SELECT * FROM tasks WHERE id = ?", duplicateId),
|
|
57
|
+
db.get("SELECT * FROM tasks WHERE id = ?", survivorId),
|
|
58
|
+
]);
|
|
59
|
+
refuseImpossible(duplicate, survivor, duplicateId, survivorId);
|
|
60
|
+
|
|
61
|
+
const held = await db.get(
|
|
62
|
+
"SELECT id, developer_id, agent_id FROM task_assignments WHERE task_id = ? AND active = 1",
|
|
63
|
+
duplicateId,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
duplicate: summary(duplicate),
|
|
68
|
+
survivor: summary(survivor),
|
|
69
|
+
moving: {
|
|
70
|
+
evidence: await countOf(db, "SELECT COUNT(*) AS n FROM verification_evidence WHERE task_id = ?", duplicateId),
|
|
71
|
+
// Counted as what the survivor would gain, not as what the duplicate has:
|
|
72
|
+
// a link the survivor already carries is not moved anywhere.
|
|
73
|
+
contractLinks: await countOf(db,
|
|
74
|
+
`SELECT COUNT(*) AS n FROM task_contract_links d
|
|
75
|
+
WHERE d.task_id = ?
|
|
76
|
+
AND NOT EXISTS (SELECT 1 FROM task_contract_links s
|
|
77
|
+
WHERE s.task_id = ? AND s.target_type = d.target_type
|
|
78
|
+
AND s.target_id = d.target_id AND s.relationship = d.relationship)`,
|
|
79
|
+
duplicateId, survivorId),
|
|
80
|
+
dependencies: await countOf(db,
|
|
81
|
+
"SELECT COUNT(*) AS n FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?", duplicateId, duplicateId),
|
|
82
|
+
memories: await countOf(db, "SELECT COUNT(*) AS n FROM memory_entries WHERE task_id = ?", duplicateId),
|
|
83
|
+
changes: await countOf(db, "SELECT COUNT(*) AS n FROM changes WHERE task_id = ?", duplicateId),
|
|
84
|
+
children: await countOf(db, "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?", duplicateId),
|
|
85
|
+
},
|
|
86
|
+
// A claim is released rather than moved, and the reader is told so before it
|
|
87
|
+
// happens rather than discovering it afterwards.
|
|
88
|
+
releasing: held ? held.id : null,
|
|
89
|
+
staying: {
|
|
90
|
+
history: await countOf(db, "SELECT COUNT(*) AS n FROM activity_events WHERE task_id = ?", duplicateId),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Move everything the duplicate owns onto the survivor and supersede it.
|
|
98
|
+
*
|
|
99
|
+
* Every move is `OR IGNORE` or guarded, because the survivor may already carry the
|
|
100
|
+
* same link, and a merge that fails halfway through on a uniqueness constraint
|
|
101
|
+
* would leave the work split across two tasks with no way to tell which half went
|
|
102
|
+
* where. The whole thing is one transaction, so it either happens or it does not.
|
|
103
|
+
*/
|
|
104
|
+
export async function mergeTasks(root, duplicateId, survivorId, { actor = "superdev", reason = null } = {}) {
|
|
105
|
+
return mutate(root, async (db) => {
|
|
106
|
+
const [duplicate, survivor] = await Promise.all([
|
|
107
|
+
db.get("SELECT * FROM tasks WHERE id = ?", duplicateId),
|
|
108
|
+
db.get("SELECT * FROM tasks WHERE id = ?", survivorId),
|
|
109
|
+
]);
|
|
110
|
+
refuseImpossible(duplicate, survivor, duplicateId, survivorId);
|
|
111
|
+
|
|
112
|
+
const moved = {};
|
|
113
|
+
|
|
114
|
+
moved.evidence = await moveRows(db,
|
|
115
|
+
"UPDATE verification_evidence SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
|
|
116
|
+
|
|
117
|
+
// Contract links are copied, not moved.
|
|
118
|
+
//
|
|
119
|
+
// Moving them emptied the duplicate, and a trigger refuses any task leaving
|
|
120
|
+
// draft without at least one contract link, so superseding it then failed and
|
|
121
|
+
// the whole merge rolled back. The trigger is right: a task in an active status
|
|
122
|
+
// that implements nothing is not a task. Copying is also the truer record,
|
|
123
|
+
// because the duplicate really was written to implement that criterion, and it
|
|
124
|
+
// is superseded rather than open so it no longer competes for the work.
|
|
125
|
+
moved.contractLinks = await moveRows(db,
|
|
126
|
+
`INSERT OR IGNORE INTO task_contract_links (task_id, target_type, target_id, relationship)
|
|
127
|
+
SELECT ?, target_type, target_id, relationship FROM task_contract_links WHERE task_id = ?`,
|
|
128
|
+
survivorId, duplicateId);
|
|
129
|
+
|
|
130
|
+
moved.dependencies = await moveRows(db,
|
|
131
|
+
"UPDATE OR IGNORE task_dependencies SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
|
|
132
|
+
moved.dependencies += await moveRows(db,
|
|
133
|
+
"UPDATE OR IGNORE task_dependencies SET depends_on_task_id = ? WHERE depends_on_task_id = ?", survivorId, duplicateId);
|
|
134
|
+
// Anything left pointed at the duplicate, and a task cannot depend on itself.
|
|
135
|
+
await db.run("DELETE FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?", duplicateId, duplicateId);
|
|
136
|
+
await db.run("DELETE FROM task_dependencies WHERE task_id = depends_on_task_id");
|
|
137
|
+
|
|
138
|
+
moved.memories = await moveRows(db,
|
|
139
|
+
"UPDATE memory_entries SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
|
|
140
|
+
moved.changes = await moveRows(db,
|
|
141
|
+
"UPDATE changes SET task_id = ? WHERE task_id = ?", survivorId, duplicateId);
|
|
142
|
+
moved.children = await moveRows(db,
|
|
143
|
+
"UPDATE tasks SET parent_task_id = ? WHERE parent_task_id = ?", survivorId, duplicateId);
|
|
144
|
+
|
|
145
|
+
// The claim is released, not moved: an assignment names a person, a machine and
|
|
146
|
+
// a branch, and moving it would put somebody's name against work they did not
|
|
147
|
+
// take.
|
|
148
|
+
const held = await db.get(
|
|
149
|
+
"SELECT id FROM task_assignments WHERE task_id = ? AND active = 1", duplicateId);
|
|
150
|
+
if (held) {
|
|
151
|
+
await db.run("UPDATE task_assignments SET active = 0, released_at = ? WHERE id = ?",
|
|
152
|
+
new Date().toISOString(), held.id);
|
|
153
|
+
}
|
|
154
|
+
await db.run(
|
|
155
|
+
"UPDATE work_sessions SET active_task_id = NULL WHERE project_id = ? AND active_task_id = ?",
|
|
156
|
+
duplicate.project_id, duplicateId);
|
|
157
|
+
|
|
158
|
+
// The pointer is written before the status moves, so nothing can observe a
|
|
159
|
+
// superseded task that does not say what replaced it.
|
|
160
|
+
await db.run("UPDATE tasks SET superseded_by = ? WHERE id = ?", survivorId, duplicateId);
|
|
161
|
+
|
|
162
|
+
const why = reason
|
|
163
|
+
? `${duplicateId} merged into ${survivorId}: ${reason}`
|
|
164
|
+
: `${duplicateId} merged into ${survivorId} as a duplicate.`;
|
|
165
|
+
await setStatus(db, "task", duplicateId, "superseded", {
|
|
166
|
+
projectId: duplicate.project_id,
|
|
167
|
+
actor,
|
|
168
|
+
note: why,
|
|
169
|
+
activityType: "task_updated",
|
|
170
|
+
activitySummary: why,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await recordActivity(db, survivor.project_id, {
|
|
174
|
+
type: "task_updated",
|
|
175
|
+
actor,
|
|
176
|
+
taskId: survivorId,
|
|
177
|
+
featureId: survivor.feature_id,
|
|
178
|
+
summary: `${survivorId} absorbed ${duplicateId}: ${describe(moved)}`,
|
|
179
|
+
metadata: { mergedFrom: duplicateId, moved },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
duplicate: summary(await db.get("SELECT * FROM tasks WHERE id = ?", duplicateId)),
|
|
184
|
+
survivor: summary(await db.get("SELECT * FROM tasks WHERE id = ?", survivorId)),
|
|
185
|
+
moved,
|
|
186
|
+
released: held?.id ?? null,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ------------------------------------------------------------------ helpers
|
|
192
|
+
|
|
193
|
+
/** Everything that makes a merge impossible, refused in one place. */
|
|
194
|
+
function refuseImpossible(duplicate, survivor, duplicateId, survivorId) {
|
|
195
|
+
if (duplicateId === survivorId) {
|
|
196
|
+
throw new MergeError(E.SAME_TASK, `${duplicateId} cannot be merged into itself.`);
|
|
197
|
+
}
|
|
198
|
+
if (!duplicate) throw new MergeError(E.NOT_FOUND, `There is no task ${duplicateId}.`);
|
|
199
|
+
if (!survivor) throw new MergeError(E.NOT_FOUND, `There is no task ${survivorId}.`);
|
|
200
|
+
if (duplicate.project_id !== survivor.project_id) {
|
|
201
|
+
throw new MergeError(E.DIFFERENT_PROJECT,
|
|
202
|
+
`${duplicateId} and ${survivorId} belong to different projects, so merging them would move work between records that do not share a scope.`);
|
|
203
|
+
}
|
|
204
|
+
if (duplicate.superseded_by) {
|
|
205
|
+
throw new MergeError(E.NOT_MERGEABLE,
|
|
206
|
+
`${duplicateId} was already merged into ${duplicate.superseded_by}. Merge that one instead.`);
|
|
207
|
+
}
|
|
208
|
+
if (CLOSED.has(duplicate.status)) {
|
|
209
|
+
throw new MergeError(E.NOT_MERGEABLE,
|
|
210
|
+
`${duplicateId} is ${duplicate.status}, so it has already been closed out and there is nothing to fold in.`);
|
|
211
|
+
}
|
|
212
|
+
if (CLOSED.has(survivor.status)) {
|
|
213
|
+
throw new MergeError(E.NOT_MERGEABLE,
|
|
214
|
+
`${survivorId} is ${survivor.status}, so merging into it would move the work onto a task nobody is going to finish.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const summary = (task) => task && ({
|
|
219
|
+
id: task.id,
|
|
220
|
+
name: task.name,
|
|
221
|
+
status: task.status,
|
|
222
|
+
featureId: task.feature_id,
|
|
223
|
+
supersededBy: task.superseded_by ?? null,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
async function countOf(db, sql, ...args) {
|
|
227
|
+
return Number((await db.get(sql, ...args))?.n ?? 0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** How many rows a statement actually moved, whichever field the driver reports it in. */
|
|
231
|
+
async function moveRows(db, sql, ...args) {
|
|
232
|
+
const result = await db.run(sql, ...args);
|
|
233
|
+
return Number(result?.changes ?? result?.rowsAffected ?? 0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const describe = (moved) => {
|
|
237
|
+
const said = Object.entries(moved)
|
|
238
|
+
.filter(([, n]) => n > 0)
|
|
239
|
+
.map(([kind, n]) => `${n} ${LABEL[kind] ?? kind}`);
|
|
240
|
+
return said.length ? said.join(", ") : "nothing to move";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const LABEL = {
|
|
244
|
+
evidence: "pieces of evidence",
|
|
245
|
+
contractLinks: "contract links copied across",
|
|
246
|
+
dependencies: "dependencies",
|
|
247
|
+
memories: "memory entries",
|
|
248
|
+
changes: "recorded changes",
|
|
249
|
+
children: "child tasks",
|
|
250
|
+
};
|
package/src/verify/index.mjs
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// saying plainly that a check is manual.
|
|
18
18
|
|
|
19
19
|
import { execFile } from "node:child_process";
|
|
20
|
+
import { realpathSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
20
22
|
import { promisify } from "node:util";
|
|
21
23
|
|
|
22
24
|
import { mutate, query } from "../db/store.mjs";
|
|
@@ -50,8 +52,86 @@ const run = promisify(execFile);
|
|
|
50
52
|
* should be run by a person who has read it.
|
|
51
53
|
*/
|
|
52
54
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A script inside the project may be run. Whether it is inside is a containment
|
|
57
|
+
* question, not a question about its path's shape.
|
|
58
|
+
*
|
|
59
|
+
* This was `/^(src\/cli\.mjs|scripts\/[A-Za-z0-9_\/-]+\.mjs)$/`, which is
|
|
60
|
+
* Superdev's own entry point and Superdev's own script directory, hardcoded. Every
|
|
61
|
+
* other repository layout was refused however plainly the script sat inside the
|
|
62
|
+
* project: a workspace package, `tools/`, `bin/`, `lib/`, `apps/web/lib/`. The
|
|
63
|
+
* refusal even said "node may only run this project's own scripts", and the
|
|
64
|
+
* allowlist did not describe this project. It described Superdev.
|
|
65
|
+
*
|
|
66
|
+
* That mattered more than a wrong message. The recorded command is the whole
|
|
67
|
+
* mechanism by which evidence stops being a one-time opinion, so on any project
|
|
68
|
+
* whose scripts are not at `scripts/*.mjs` the mechanism quietly did nothing, and
|
|
69
|
+
* a reader was pushed into moving their files to suit the tool.
|
|
70
|
+
*
|
|
71
|
+
* The real risk was never the path's shape. It is a node option before the script
|
|
72
|
+
* path, which can load arbitrary code, and that rule is enforced separately and
|
|
73
|
+
* unchanged. What containment has to add is that the script is under the project
|
|
74
|
+
* root once symlinks are resolved, so a link pointing out of the tree is refused
|
|
75
|
+
* even though its path looks local.
|
|
76
|
+
*/
|
|
77
|
+
const SCRIPT_EXTENSION = /\.(mjs|cjs|js)$/;
|
|
78
|
+
|
|
79
|
+
function outsideProject(root, script) {
|
|
80
|
+
// A flag in the script position is the actual risk, and it is refused on shape
|
|
81
|
+
// rather than on containment: `--require=./evil.js` ends in `.js` and names no
|
|
82
|
+
// script at all. Checking the extension first let exactly that through.
|
|
83
|
+
if (script.startsWith("-")) {
|
|
84
|
+
return "node may not be given an option before the script path, because an option can load arbitrary code";
|
|
85
|
+
}
|
|
86
|
+
if (!SCRIPT_EXTENSION.test(script)) return `not a script: ${script}`;
|
|
87
|
+
if (isAbsolute(script)) return `an absolute path names a place rather than something in this project: ${script}`;
|
|
88
|
+
if (!root) {
|
|
89
|
+
return `the project root is not known here, so whether ${script} is inside it cannot be decided`;
|
|
90
|
+
}
|
|
91
|
+
const base = realpathOrSelf(resolve(root));
|
|
92
|
+
const target = resolvedWithin(base, script);
|
|
93
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
94
|
+
return `node may only run scripts inside this project, and ${script} resolves outside it`;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The script's real location, resolving every symlink on the way to it.
|
|
101
|
+
*
|
|
102
|
+
* realpath on the whole path answers nothing when the file does not exist yet, and
|
|
103
|
+
* falling back to the unresolved path let a directory symlink out of the tree
|
|
104
|
+
* pass: `escape/hosts.mjs`, where `escape` links to somewhere else, resolves under
|
|
105
|
+
* the root only because the leaf could not be resolved. So the deepest ancestor
|
|
106
|
+
* that does exist is resolved instead, and the rest is appended to it.
|
|
107
|
+
*/
|
|
108
|
+
function resolvedWithin(base, script) {
|
|
109
|
+
const full = resolve(base, script);
|
|
110
|
+
let head = full;
|
|
111
|
+
const tail = [];
|
|
112
|
+
for (;;) {
|
|
113
|
+
const real = realpathSafe(head);
|
|
114
|
+
if (real) return tail.length ? resolve(real, ...tail.reverse()) : real;
|
|
115
|
+
const parent = dirname(head);
|
|
116
|
+
// Reached the filesystem root without finding anything that exists.
|
|
117
|
+
if (parent === head) return full;
|
|
118
|
+
tail.push(basename(head));
|
|
119
|
+
head = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const realpathSafe = (path) => {
|
|
124
|
+
try {
|
|
125
|
+
return realpathSync(path);
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Symlinks resolved where possible, so a link out of the tree cannot hide. */
|
|
132
|
+
function realpathOrSelf(path) {
|
|
133
|
+
return realpathSafe(path) ?? path;
|
|
134
|
+
}
|
|
55
135
|
|
|
56
136
|
/**
|
|
57
137
|
* Flags each program may carry.
|
|
@@ -71,7 +151,7 @@ const CHANGES_STATE = /^--(apply|force|write|fix|delete|remove)\b/;
|
|
|
71
151
|
export const E = { UNSAFE: "E_UNSAFE_CHECK" };
|
|
72
152
|
|
|
73
153
|
/** Why a command will not be run here, or null when it will. */
|
|
74
|
-
export function refuseReason(command) {
|
|
154
|
+
export function refuseReason(command, root = null) {
|
|
75
155
|
const text = String(command ?? "").trim();
|
|
76
156
|
if (!text) return "no command recorded";
|
|
77
157
|
// Shell punctuation cannot do anything through execFile, but its presence
|
|
@@ -96,7 +176,8 @@ export function refuseReason(command) {
|
|
|
96
176
|
// arbitrary code, which is the whole risk with running node at all.
|
|
97
177
|
const [script, ...args] = rest;
|
|
98
178
|
if (!script) return "node with no script";
|
|
99
|
-
|
|
179
|
+
const outside = outsideProject(root, script);
|
|
180
|
+
if (outside) return outside;
|
|
100
181
|
for (const a of args) {
|
|
101
182
|
if (CHANGES_STATE.test(a)) return "it would change something, and a check must not";
|
|
102
183
|
}
|
|
@@ -152,7 +233,7 @@ export function tokenize(command) {
|
|
|
152
233
|
* validator which starts finding things is how a claim stops being true.
|
|
153
234
|
*/
|
|
154
235
|
export async function runCheck(root, command, { timeoutMs = 120000 } = {}) {
|
|
155
|
-
const refused = refuseReason(command);
|
|
236
|
+
const refused = refuseReason(command, root);
|
|
156
237
|
if (refused) return { result: "error", detail: refused };
|
|
157
238
|
|
|
158
239
|
const [bin, ...rest] = tokenize(command);
|