superdev-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
// One function per GET route in MODULE_CONTRACT.md.
|
|
2
|
+
//
|
|
3
|
+
// The control center reads nothing but these payloads and never touches the
|
|
4
|
+
// filesystem, so every question a screen asks has to be answerable from here.
|
|
5
|
+
// Each function opens one readonly connection, builds its whole payload inside
|
|
6
|
+
// it, and stamps the revision it was built from. Two payloads never disagree
|
|
7
|
+
// because each is a single pinned snapshot.
|
|
8
|
+
//
|
|
9
|
+
// Progress is never recomputed here. It comes from ../progress/index.mjs, which
|
|
10
|
+
// the CLI also uses, so the number in the terminal is the number in the browser.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
query,
|
|
14
|
+
json,
|
|
15
|
+
DbError,
|
|
16
|
+
} from "../db/store.mjs";
|
|
17
|
+
import {
|
|
18
|
+
featureProgress,
|
|
19
|
+
moduleProgress,
|
|
20
|
+
milestoneProgress,
|
|
21
|
+
goalProgress,
|
|
22
|
+
projectProgress,
|
|
23
|
+
readiness,
|
|
24
|
+
freshness,
|
|
25
|
+
nextAction,
|
|
26
|
+
alignmentWarnings,
|
|
27
|
+
honestPercent,
|
|
28
|
+
SESSION_STALE_MS,
|
|
29
|
+
} from "../progress/index.mjs";
|
|
30
|
+
import { MODULE_STEPS, agrees, count, titleCase } from "../model/vocabulary.mjs";
|
|
31
|
+
|
|
32
|
+
const marks = (n) => Array.from({ length: n }, () => "?").join(",");
|
|
33
|
+
const nowIso = () => new Date().toISOString();
|
|
34
|
+
|
|
35
|
+
/** A session quiet for longer than this is Idle rather than Live. */
|
|
36
|
+
const LIVE_WITHIN_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** How many plain-language lines the overview lists before it stops. */
|
|
39
|
+
const OVERVIEW_LINES = 8;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every `*_json` column is stored as text and consumed as structured data. The
|
|
43
|
+
* interface types declare arrays and objects, so the parse happens once here
|
|
44
|
+
* rather than in every component.
|
|
45
|
+
*/
|
|
46
|
+
function hydrate(row) {
|
|
47
|
+
if (!row) return row ?? null;
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [key, value] of Object.entries(row)) {
|
|
50
|
+
out[key] = key.endsWith("_json") ? json(value, null) : value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const hydrateAll = (rows) => rows.map(hydrate);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The progress shape the interface renders.
|
|
59
|
+
*
|
|
60
|
+
* `counts` is the NOUN the totals are counted in, because the interface builds
|
|
61
|
+
* "5 of 47 readiness items addressed" from it and then pluralizes it. The
|
|
62
|
+
* per-component breakdown travels separately as a list: joining the breakdown
|
|
63
|
+
* into this field produced a run-on headline with a pluralizing "s" stuck on
|
|
64
|
+
* the end of the last sentence, which is what the lists are for avoiding.
|
|
65
|
+
*/
|
|
66
|
+
function toProgress(p) {
|
|
67
|
+
return {
|
|
68
|
+
...p,
|
|
69
|
+
counts: p.unit ?? "tracked item",
|
|
70
|
+
countsBreakdown: p.whatCounts ?? [],
|
|
71
|
+
remainsBreakdown: p.whatRemains ?? [],
|
|
72
|
+
// Joined with a space, three clauses ran together as one unreadable line:
|
|
73
|
+
// "Accepted features delivered: 1 outstanding Milestones reached: 2
|
|
74
|
+
// outstanding Goal success criteria met: 2 outstanding".
|
|
75
|
+
remains: p.whatRemains?.length ? p.whatRemains.join("; ") : null,
|
|
76
|
+
computedAt: p.generatedAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function revisionOf(db) {
|
|
81
|
+
return (await db.value("SELECT max(sequence) FROM activity_events")) ?? 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function projectOf(db) {
|
|
85
|
+
const project = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
86
|
+
if (!project) {
|
|
87
|
+
throw new DbError(
|
|
88
|
+
"E_NOT_FOUND",
|
|
89
|
+
"This directory has no Superdev project yet. Run `superdev init` in the project root, then reload.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return project;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run one read and wrap it in the freshness envelope. The revision is read from
|
|
97
|
+
* the same pinned snapshot the payload came from, so it always describes
|
|
98
|
+
* exactly the data that is being returned.
|
|
99
|
+
*/
|
|
100
|
+
async function envelope(root, build) {
|
|
101
|
+
return query(root, async (db) => {
|
|
102
|
+
const project = await projectOf(db);
|
|
103
|
+
const data = await build(db, project);
|
|
104
|
+
const revision = await revisionOf(db);
|
|
105
|
+
return {
|
|
106
|
+
data,
|
|
107
|
+
meta: {
|
|
108
|
+
revision,
|
|
109
|
+
lastEventSequence: revision,
|
|
110
|
+
generatedAt: nowIso(),
|
|
111
|
+
// A fresh connection per read is pinned to the present, so a payload
|
|
112
|
+
// that was just built cannot already lag the database.
|
|
113
|
+
stale: false,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Saved canvas positions for one blueprint surface. */
|
|
120
|
+
async function layoutFor(db, projectId, canvas) {
|
|
121
|
+
const rows = await db.all(
|
|
122
|
+
"SELECT * FROM layout_positions WHERE project_id = ? AND canvas = ?",
|
|
123
|
+
projectId,
|
|
124
|
+
canvas,
|
|
125
|
+
);
|
|
126
|
+
return rows.map((r) => ({ id: r.record_id, recordType: r.record_type, x: r.x, y: r.y }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The primary flow lives in its own table, so features carry it as a list. */
|
|
130
|
+
async function flowsByFeature(db, featureIds) {
|
|
131
|
+
const map = new Map(featureIds.map((id) => [id, []]));
|
|
132
|
+
if (!featureIds.length) return map;
|
|
133
|
+
const rows = await db.all(
|
|
134
|
+
`SELECT * FROM feature_flows WHERE feature_id IN (${marks(featureIds.length)})
|
|
135
|
+
ORDER BY feature_id, sequence`,
|
|
136
|
+
...featureIds,
|
|
137
|
+
);
|
|
138
|
+
for (const row of rows) map.get(row.feature_id)?.push(row.step);
|
|
139
|
+
return map;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------------------------------------------------------------------ overview
|
|
143
|
+
|
|
144
|
+
export function overview(root) {
|
|
145
|
+
return envelope(root, async (db, project) => {
|
|
146
|
+
const [progress, fresh, next, warnings] = [
|
|
147
|
+
await projectProgress(db, project.id),
|
|
148
|
+
await freshness(db, project.id),
|
|
149
|
+
await nextAction(db, project.id),
|
|
150
|
+
await alignmentWarnings(db, project.id),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const features = hydrateAll(await db.all("SELECT * FROM features WHERE project_id = ?", project.id));
|
|
154
|
+
const tasks = await db.all("SELECT * FROM tasks WHERE project_id = ?", project.id);
|
|
155
|
+
const milestones = await db.all("SELECT * FROM milestones WHERE project_id = ?", project.id);
|
|
156
|
+
const questions = await db.all(
|
|
157
|
+
"SELECT * FROM questions WHERE project_id = ? AND status = 'open'",
|
|
158
|
+
project.id,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const liveFeatures = features.filter((f) => !["cancelled", "superseded"].includes(f.status));
|
|
162
|
+
const openTasks = tasks.filter((t) => !["complete", "cancelled", "superseded"].includes(t.status));
|
|
163
|
+
const inFlight = openTasks.filter((t) => ["in_progress", "in_review", "verifying"].includes(t.status));
|
|
164
|
+
const blockedTasks = openTasks.filter((t) => t.status === "blocked");
|
|
165
|
+
|
|
166
|
+
const headline = [
|
|
167
|
+
{ label: "Features in scope", value: liveFeatures.length, unit: "feature", href: "#/product" },
|
|
168
|
+
{
|
|
169
|
+
label: "Features complete",
|
|
170
|
+
value: liveFeatures.filter((f) => f.status === "complete").length,
|
|
171
|
+
unit: "feature",
|
|
172
|
+
href: "#/product",
|
|
173
|
+
},
|
|
174
|
+
{ label: "Work in progress", value: inFlight.length, unit: "task", href: "#/tasks" },
|
|
175
|
+
{ label: "Blocked", value: blockedTasks.length, unit: "task", href: "#/tasks" },
|
|
176
|
+
{ label: "Open tasks", value: openTasks.length, unit: "task", href: "#/tasks" },
|
|
177
|
+
{ label: "Open questions", value: questions.length, unit: "question", href: "#/discovery" },
|
|
178
|
+
{
|
|
179
|
+
label: "Milestones reached",
|
|
180
|
+
value: milestones.filter((m) => ["complete", "accepted", "delivered"].includes(m.status)).length,
|
|
181
|
+
unit: "milestone",
|
|
182
|
+
href: "#/product",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const whatWorks = [];
|
|
187
|
+
for (const feature of liveFeatures) {
|
|
188
|
+
if (feature.what_works_now) whatWorks.push(`${feature.name}: ${feature.what_works_now}`);
|
|
189
|
+
else if (feature.status === "complete") whatWorks.push(`${feature.name} is complete.`);
|
|
190
|
+
if (whatWorks.length >= OVERVIEW_LINES) break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const whatIsBlocked = blockedTasks
|
|
194
|
+
.slice(0, OVERVIEW_LINES)
|
|
195
|
+
.map((t) => `${t.name}: ${t.block_reason ?? "blocked with no reason recorded."}`);
|
|
196
|
+
for (const warning of warnings) {
|
|
197
|
+
if (whatIsBlocked.length >= OVERVIEW_LINES) break;
|
|
198
|
+
if (warning.severity === "high") whatIsBlocked.push(`${warning.title}: ${warning.detail}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const activeWork = await activeWorkItems(db, inFlight, features);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
project: hydrate(project),
|
|
205
|
+
headline,
|
|
206
|
+
whatWorks,
|
|
207
|
+
whatIsBlocked,
|
|
208
|
+
activeWork,
|
|
209
|
+
nextAction: {
|
|
210
|
+
...next,
|
|
211
|
+
summary: next.title,
|
|
212
|
+
reason: next.detail,
|
|
213
|
+
targetType: next.recordType,
|
|
214
|
+
targetId: next.recordId,
|
|
215
|
+
href: hrefFor(next.recordType, next.recordId),
|
|
216
|
+
},
|
|
217
|
+
warnings,
|
|
218
|
+
progress: toProgress(progress),
|
|
219
|
+
freshness: {
|
|
220
|
+
...fresh,
|
|
221
|
+
databaseRevision: fresh.revision,
|
|
222
|
+
activeSessionHeartbeatAt: fresh.lastSessionHeartbeatAt,
|
|
223
|
+
branchHead: fresh.branches[0]?.headRevision ?? null,
|
|
224
|
+
staleEvidenceCount: fresh.staleEvidence.length,
|
|
225
|
+
pendingDocProposalCount: fresh.pendingDocumentProposals.length,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hrefFor(recordType, recordId) {
|
|
232
|
+
if (!recordType || !recordId) return null;
|
|
233
|
+
const route = {
|
|
234
|
+
task: "#/tasks",
|
|
235
|
+
feature: "#/features",
|
|
236
|
+
module: "#/product",
|
|
237
|
+
milestone: "#/product",
|
|
238
|
+
goal: "#/product",
|
|
239
|
+
question: "#/discovery",
|
|
240
|
+
decision: "#/decisions",
|
|
241
|
+
verification_evidence: "#/tasks",
|
|
242
|
+
feature_acceptance_criterion: "#/features",
|
|
243
|
+
}[recordType];
|
|
244
|
+
return route ? `${route}/${recordId}` : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function activeWorkItems(db, tasks, features) {
|
|
248
|
+
if (!tasks.length) return [];
|
|
249
|
+
const featureName = new Map(features.map((f) => [f.id, f.name]));
|
|
250
|
+
const assignments = await db.all(
|
|
251
|
+
`SELECT * FROM task_assignments WHERE active = 1 AND task_id IN (${marks(tasks.length)})`,
|
|
252
|
+
...tasks.map((t) => t.id),
|
|
253
|
+
);
|
|
254
|
+
const byTask = new Map(assignments.map((a) => [a.task_id, a]));
|
|
255
|
+
const developers = new Map(
|
|
256
|
+
(await db.all("SELECT * FROM developers")).map((d) => [d.id, d.display_name]),
|
|
257
|
+
);
|
|
258
|
+
const agents = new Map(
|
|
259
|
+
(await db.all("SELECT * FROM agents")).map((a) => [a.id, a.model_label ?? a.harness]),
|
|
260
|
+
);
|
|
261
|
+
const branches = new Map((await db.all("SELECT * FROM branches")).map((b) => [b.id, b.name]));
|
|
262
|
+
const sessions = await db.all("SELECT * FROM work_sessions WHERE status = 'active'");
|
|
263
|
+
const sessionByTask = new Map(sessions.filter((s) => s.active_task_id).map((s) => [s.active_task_id, s]));
|
|
264
|
+
|
|
265
|
+
return tasks.map((task) => {
|
|
266
|
+
const assignment = byTask.get(task.id);
|
|
267
|
+
const session = sessionByTask.get(task.id);
|
|
268
|
+
return {
|
|
269
|
+
taskId: task.id,
|
|
270
|
+
taskName: task.name,
|
|
271
|
+
featureId: task.feature_id,
|
|
272
|
+
featureName: featureName.get(task.feature_id) ?? null,
|
|
273
|
+
status: task.status,
|
|
274
|
+
actor:
|
|
275
|
+
(assignment && (developers.get(assignment.developer_id) ?? agents.get(assignment.agent_id))) ??
|
|
276
|
+
null,
|
|
277
|
+
branch: assignment ? branches.get(assignment.branch_id) ?? null : null,
|
|
278
|
+
startedAt: task.started_at,
|
|
279
|
+
lastActivityAt: session?.last_activity_at ?? task.updated_at,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ----------------------------------------------------------------- discovery
|
|
285
|
+
|
|
286
|
+
export function discovery(root) {
|
|
287
|
+
return envelope(root, async (db, project) => {
|
|
288
|
+
const items = hydrateAll(
|
|
289
|
+
await db.all(
|
|
290
|
+
"SELECT * FROM discovery_items WHERE project_id = ? ORDER BY created_at, id",
|
|
291
|
+
project.id,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
const ids = items.map((i) => i.id);
|
|
295
|
+
const links = ids.length
|
|
296
|
+
? await db.all(
|
|
297
|
+
`SELECT * FROM discovery_links WHERE from_id IN (${marks(ids.length)})`,
|
|
298
|
+
...ids,
|
|
299
|
+
)
|
|
300
|
+
: [];
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
sources: hydrateAll(
|
|
304
|
+
await db.all(
|
|
305
|
+
"SELECT * FROM source_material WHERE project_id = ? ORDER BY received_at, id",
|
|
306
|
+
project.id,
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
items,
|
|
310
|
+
links,
|
|
311
|
+
questions: hydrateAll(
|
|
312
|
+
await db.all("SELECT * FROM questions WHERE project_id = ? ORDER BY created_at, id", project.id),
|
|
313
|
+
),
|
|
314
|
+
assumptions: items.filter((i) => i.kind === "assumption"),
|
|
315
|
+
risks: items.filter((i) => i.kind === "risk"),
|
|
316
|
+
// A concept dragged on the mind map keeps its position on the item row;
|
|
317
|
+
// the layout table covers records that have no position of their own.
|
|
318
|
+
layout: [
|
|
319
|
+
...items
|
|
320
|
+
.filter((i) => i.position_x !== null && i.position_y !== null)
|
|
321
|
+
.map((i) => ({ id: i.id, recordType: "discovery_item", x: i.position_x, y: i.position_y })),
|
|
322
|
+
...(await layoutFor(db, project.id, "discovery")),
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ------------------------------------------------------------------- product
|
|
329
|
+
|
|
330
|
+
export function product(root) {
|
|
331
|
+
return envelope(root, async (db, project) => {
|
|
332
|
+
const goals = hydrateAll(
|
|
333
|
+
await db.all("SELECT * FROM goals WHERE project_id = ? ORDER BY sequence, id", project.id),
|
|
334
|
+
);
|
|
335
|
+
const milestones = hydrateAll(
|
|
336
|
+
await db.all("SELECT * FROM milestones WHERE project_id = ? ORDER BY sequence, id", project.id),
|
|
337
|
+
);
|
|
338
|
+
const modules = hydrateAll(
|
|
339
|
+
await db.all("SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id", project.id),
|
|
340
|
+
);
|
|
341
|
+
const features = hydrateAll(
|
|
342
|
+
await db.all("SELECT * FROM features WHERE project_id = ? ORDER BY module_id, name", project.id),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const criteria = goals.length
|
|
346
|
+
? await db.all(
|
|
347
|
+
`SELECT * FROM goal_success_criteria WHERE goal_id IN (${marks(goals.length)})
|
|
348
|
+
ORDER BY goal_id, sequence`,
|
|
349
|
+
...goals.map((g) => g.id),
|
|
350
|
+
)
|
|
351
|
+
: [];
|
|
352
|
+
const featureGoals = await db.all("SELECT * FROM feature_goals");
|
|
353
|
+
const flows = await flowsByFeature(db, features.map((f) => f.id));
|
|
354
|
+
const scope = await db.all(
|
|
355
|
+
"SELECT * FROM project_scope_items WHERE project_id = ? ORDER BY direction, sequence",
|
|
356
|
+
project.id,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
goals: await Promise.all(
|
|
361
|
+
goals.map(async (goal) => ({
|
|
362
|
+
...goal,
|
|
363
|
+
successCriteria: criteria.filter((c) => c.goal_id === goal.id),
|
|
364
|
+
progress: toProgress(await goalProgress(db, goal.id)),
|
|
365
|
+
})),
|
|
366
|
+
),
|
|
367
|
+
milestones: await Promise.all(
|
|
368
|
+
milestones.map(async (m) => ({ ...m, progress: toProgress(await milestoneProgress(db, m.id)) })),
|
|
369
|
+
),
|
|
370
|
+
modules: await Promise.all(
|
|
371
|
+
modules.map(async (m) => ({ ...m, progress: toProgress(await moduleProgress(db, m.id)) })),
|
|
372
|
+
),
|
|
373
|
+
features: await Promise.all(
|
|
374
|
+
features.map(async (f) => ({
|
|
375
|
+
...f,
|
|
376
|
+
primary_flow_json: flows.get(f.id) ?? [],
|
|
377
|
+
goalIds: featureGoals.filter((fg) => fg.feature_id === f.id).map((fg) => fg.goal_id),
|
|
378
|
+
progress: toProgress(await featureProgress(db, f.id)),
|
|
379
|
+
})),
|
|
380
|
+
),
|
|
381
|
+
scope,
|
|
382
|
+
// The blueprint canvas draws from this payload and reads its saved
|
|
383
|
+
// positions out of `layout`. Without this the canvas asked for a layout
|
|
384
|
+
// on every load, always got nothing, and every arrangement anyone made
|
|
385
|
+
// was lost on refresh.
|
|
386
|
+
layout: await layoutFor(db, project.id, "blueprint"),
|
|
387
|
+
progress: toProgress(await projectProgress(db, project.id)),
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ------------------------------------------------------------ feature detail
|
|
393
|
+
|
|
394
|
+
export function feature(root, featureId) {
|
|
395
|
+
return envelope(root, async (db) => {
|
|
396
|
+
const row = await db.get("SELECT * FROM features WHERE id = ?", featureId);
|
|
397
|
+
if (!row) {
|
|
398
|
+
throw new DbError("E_NOT_FOUND", `There is no feature ${featureId}. Check the id, or open the Product view to pick one.`);
|
|
399
|
+
}
|
|
400
|
+
const record = hydrate(row);
|
|
401
|
+
const flows = await flowsByFeature(db, [featureId]);
|
|
402
|
+
|
|
403
|
+
const workflows = hydrateAll(
|
|
404
|
+
await db.all("SELECT * FROM workflows WHERE feature_id = ? ORDER BY name", featureId),
|
|
405
|
+
);
|
|
406
|
+
const surfaces = hydrateAll(
|
|
407
|
+
await db.all("SELECT * FROM surfaces WHERE feature_id = ? ORDER BY name", featureId),
|
|
408
|
+
);
|
|
409
|
+
const uiActions = surfaces.length
|
|
410
|
+
? hydrateAll(
|
|
411
|
+
await db.all(
|
|
412
|
+
`SELECT * FROM ui_actions WHERE surface_id IN (${marks(surfaces.length)}) ORDER BY surface_id, name`,
|
|
413
|
+
...surfaces.map((s) => s.id),
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
: [];
|
|
417
|
+
const surfaceStates = surfaces.length
|
|
418
|
+
? await db.all(
|
|
419
|
+
`SELECT * FROM surface_states WHERE surface_id IN (${marks(surfaces.length)})`,
|
|
420
|
+
...surfaces.map((s) => s.id),
|
|
421
|
+
)
|
|
422
|
+
: [];
|
|
423
|
+
|
|
424
|
+
const goalIds = (await db.all("SELECT * FROM feature_goals WHERE feature_id = ?", featureId)).map(
|
|
425
|
+
(fg) => fg.goal_id,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const migrations = await db.all(
|
|
429
|
+
"SELECT * FROM schema_migrations WHERE feature_id = ? ORDER BY sequence",
|
|
430
|
+
featureId,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
feature: { ...record, primary_flow_json: flows.get(featureId) ?? [], goalIds },
|
|
435
|
+
module: hydrate(await db.get("SELECT * FROM modules WHERE id = ?", record.module_id)),
|
|
436
|
+
milestone: record.milestone_id
|
|
437
|
+
? hydrate(await db.get("SELECT * FROM milestones WHERE id = ?", record.milestone_id))
|
|
438
|
+
: null,
|
|
439
|
+
goals: goalIds.length
|
|
440
|
+
? hydrateAll(await db.all(`SELECT * FROM goals WHERE id IN (${marks(goalIds.length)})`, ...goalIds))
|
|
441
|
+
: [],
|
|
442
|
+
acceptanceCriteria: await db.all(
|
|
443
|
+
"SELECT * FROM feature_acceptance_criteria WHERE feature_id = ? ORDER BY sequence",
|
|
444
|
+
featureId,
|
|
445
|
+
),
|
|
446
|
+
edgeCases: await db.all(
|
|
447
|
+
"SELECT * FROM feature_edge_cases WHERE feature_id = ? ORDER BY category",
|
|
448
|
+
featureId,
|
|
449
|
+
),
|
|
450
|
+
workflows,
|
|
451
|
+
surfaces,
|
|
452
|
+
surfaceStates,
|
|
453
|
+
uiActions,
|
|
454
|
+
apiOperations: hydrateAll(
|
|
455
|
+
await db.all("SELECT * FROM api_operations WHERE feature_id = ? ORDER BY name", featureId),
|
|
456
|
+
),
|
|
457
|
+
dataEntities: hydrateAll(
|
|
458
|
+
await db.all("SELECT * FROM data_entities WHERE feature_id = ? ORDER BY name", featureId),
|
|
459
|
+
),
|
|
460
|
+
migrations,
|
|
461
|
+
integrations: hydrateAll(
|
|
462
|
+
await db.all("SELECT * FROM integrations WHERE feature_id = ? ORDER BY name", featureId),
|
|
463
|
+
),
|
|
464
|
+
jobs: hydrateAll(await db.all("SELECT * FROM jobs WHERE feature_id = ? ORDER BY name", featureId)),
|
|
465
|
+
webhooks: hydrateAll(
|
|
466
|
+
await db.all("SELECT * FROM webhooks WHERE feature_id = ? ORDER BY name", featureId),
|
|
467
|
+
),
|
|
468
|
+
nfrs: await db.all(
|
|
469
|
+
"SELECT * FROM non_functional_requirements WHERE feature_id = ? ORDER BY category",
|
|
470
|
+
featureId,
|
|
471
|
+
),
|
|
472
|
+
decisions: hydrateAll(
|
|
473
|
+
await db.all(
|
|
474
|
+
`SELECT d.* FROM decisions d
|
|
475
|
+
WHERE (d.scope_type = 'feature' AND d.scope_id = ?)
|
|
476
|
+
OR d.id IN (SELECT decision_id FROM decision_links
|
|
477
|
+
WHERE target_type = 'feature' AND target_id = ?)
|
|
478
|
+
ORDER BY d.created_at`,
|
|
479
|
+
featureId,
|
|
480
|
+
featureId,
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
tasks: hydrateAll(
|
|
484
|
+
await db.all("SELECT * FROM tasks WHERE feature_id = ? ORDER BY sequence, id", featureId),
|
|
485
|
+
),
|
|
486
|
+
evidence: await db.all(
|
|
487
|
+
"SELECT * FROM verification_evidence WHERE feature_id = ? ORDER BY recorded_at DESC",
|
|
488
|
+
featureId,
|
|
489
|
+
),
|
|
490
|
+
progress: toProgress(await featureProgress(db, featureId)),
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ----------------------------------------------------------------- workflows
|
|
496
|
+
|
|
497
|
+
export function workflows(root) {
|
|
498
|
+
return envelope(root, async (db, project) => {
|
|
499
|
+
const rows = hydrateAll(
|
|
500
|
+
await db.all(
|
|
501
|
+
`SELECT w.* FROM workflows w
|
|
502
|
+
JOIN features f ON f.id = w.feature_id
|
|
503
|
+
WHERE f.project_id = ? ORDER BY w.name`,
|
|
504
|
+
project.id,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
const ids = rows.map((w) => w.id);
|
|
508
|
+
|
|
509
|
+
const actorRows = ids.length
|
|
510
|
+
? await db.all(
|
|
511
|
+
`SELECT * FROM workflow_actors WHERE workflow_id IN (${marks(ids.length)}) ORDER BY workflow_id, sequence`,
|
|
512
|
+
...ids,
|
|
513
|
+
)
|
|
514
|
+
: [];
|
|
515
|
+
const steps = ids.length
|
|
516
|
+
? await db.all(
|
|
517
|
+
`SELECT * FROM workflow_steps WHERE workflow_id IN (${marks(ids.length)}) ORDER BY workflow_id, sequence`,
|
|
518
|
+
...ids,
|
|
519
|
+
)
|
|
520
|
+
: [];
|
|
521
|
+
const branches = ids.length
|
|
522
|
+
? await db.all(`SELECT * FROM workflow_branches WHERE workflow_id IN (${marks(ids.length)})`, ...ids)
|
|
523
|
+
: [];
|
|
524
|
+
|
|
525
|
+
// The execution overlay: which tasks claim to implement each step.
|
|
526
|
+
const stepLinks = await db.all(
|
|
527
|
+
"SELECT * FROM task_contract_links WHERE target_type = 'workflow_step'",
|
|
528
|
+
);
|
|
529
|
+
const tasksByStep = new Map();
|
|
530
|
+
for (const link of stepLinks) {
|
|
531
|
+
if (!tasksByStep.has(link.target_id)) tasksByStep.set(link.target_id, []);
|
|
532
|
+
tasksByStep.get(link.target_id).push(link.task_id);
|
|
533
|
+
}
|
|
534
|
+
const taskStatus = new Map(
|
|
535
|
+
(await db.all("SELECT id, status FROM tasks WHERE project_id = ?", project.id)).map((t) => [
|
|
536
|
+
t.id,
|
|
537
|
+
t.status,
|
|
538
|
+
]),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const machines = await db.all(
|
|
542
|
+
`SELECT * FROM state_machines
|
|
543
|
+
WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?)
|
|
544
|
+
OR feature_id IN (SELECT id FROM features WHERE project_id = ?)`,
|
|
545
|
+
project.id,
|
|
546
|
+
project.id,
|
|
547
|
+
);
|
|
548
|
+
const machineIds = machines.map((m) => m.id);
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
workflows: rows.map((w) => ({
|
|
552
|
+
...w,
|
|
553
|
+
actors_json: actorRows.filter((a) => a.workflow_id === w.id).map((a) => a.actor),
|
|
554
|
+
})),
|
|
555
|
+
steps: steps.map((s) => ({
|
|
556
|
+
...s,
|
|
557
|
+
taskIds: tasksByStep.get(s.id) ?? [],
|
|
558
|
+
taskStatuses: (tasksByStep.get(s.id) ?? []).map((id) => taskStatus.get(id) ?? "unknown"),
|
|
559
|
+
})),
|
|
560
|
+
branches,
|
|
561
|
+
actors: [...new Set(actorRows.map((a) => a.actor))].sort(),
|
|
562
|
+
stateMachines: machines,
|
|
563
|
+
states: machineIds.length
|
|
564
|
+
? hydrateAll(
|
|
565
|
+
await db.all(
|
|
566
|
+
`SELECT * FROM states WHERE state_machine_id IN (${marks(machineIds.length)}) ORDER BY state_machine_id, sequence`,
|
|
567
|
+
...machineIds,
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
: [],
|
|
571
|
+
transitions: machineIds.length
|
|
572
|
+
? await db.all(
|
|
573
|
+
`SELECT * FROM state_transitions WHERE state_machine_id IN (${marks(machineIds.length)})`,
|
|
574
|
+
...machineIds,
|
|
575
|
+
)
|
|
576
|
+
: [],
|
|
577
|
+
layout: await layoutFor(db, project.id, "workflow"),
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------- data
|
|
583
|
+
|
|
584
|
+
export function data(root) {
|
|
585
|
+
return envelope(root, async (db, project) => {
|
|
586
|
+
const entities = hydrateAll(
|
|
587
|
+
await db.all(
|
|
588
|
+
`SELECT e.* FROM data_entities e
|
|
589
|
+
JOIN modules m ON m.id = e.module_id
|
|
590
|
+
WHERE m.project_id = ? ORDER BY e.name`,
|
|
591
|
+
project.id,
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
const ids = entities.map((e) => e.id);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
entities,
|
|
598
|
+
fields: ids.length
|
|
599
|
+
? hydrateAll(
|
|
600
|
+
await db.all(
|
|
601
|
+
`SELECT * FROM data_fields WHERE entity_id IN (${marks(ids.length)}) ORDER BY entity_id, sequence`,
|
|
602
|
+
...ids,
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
: [],
|
|
606
|
+
relationships: ids.length
|
|
607
|
+
? await db.all(
|
|
608
|
+
`SELECT * FROM data_relationships WHERE from_entity_id IN (${marks(ids.length)})`,
|
|
609
|
+
...ids,
|
|
610
|
+
)
|
|
611
|
+
: [],
|
|
612
|
+
migrations: await db.all(
|
|
613
|
+
"SELECT * FROM schema_migrations WHERE project_id = ? ORDER BY sequence",
|
|
614
|
+
project.id,
|
|
615
|
+
),
|
|
616
|
+
migrationEntities: await db.all(
|
|
617
|
+
`SELECT * FROM schema_migration_entities
|
|
618
|
+
WHERE migration_id IN (SELECT id FROM schema_migrations WHERE project_id = ?)`,
|
|
619
|
+
project.id,
|
|
620
|
+
),
|
|
621
|
+
layout: [
|
|
622
|
+
...entities
|
|
623
|
+
.filter((e) => e.position_x !== null && e.position_y !== null)
|
|
624
|
+
.map((e) => ({ id: e.id, recordType: "data_entity", x: e.position_x, y: e.position_y })),
|
|
625
|
+
...(await layoutFor(db, project.id, "data")),
|
|
626
|
+
],
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// -------------------------------------------------------------- architecture
|
|
632
|
+
|
|
633
|
+
export function architecture(root) {
|
|
634
|
+
return envelope(root, async (db, project) => {
|
|
635
|
+
const pieces = await db.all(
|
|
636
|
+
"SELECT * FROM runtime_pieces WHERE project_id = ? ORDER BY sequence, name",
|
|
637
|
+
project.id,
|
|
638
|
+
);
|
|
639
|
+
const pieceIds = pieces.map((p) => p.id);
|
|
640
|
+
const edges = pieceIds.length
|
|
641
|
+
? await db.all(
|
|
642
|
+
`SELECT * FROM runtime_piece_edges WHERE from_piece_id IN (${marks(pieceIds.length)})`,
|
|
643
|
+
...pieceIds,
|
|
644
|
+
)
|
|
645
|
+
: [];
|
|
646
|
+
|
|
647
|
+
const modules = hydrateAll(
|
|
648
|
+
await db.all("SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id", project.id),
|
|
649
|
+
);
|
|
650
|
+
const dependencies = moduleDependencies(modules);
|
|
651
|
+
const entities = await db.all(
|
|
652
|
+
`SELECT id, name, module_id FROM data_entities
|
|
653
|
+
WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?)`,
|
|
654
|
+
project.id,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
pieces: pieces.map((p) => ({
|
|
659
|
+
id: p.id,
|
|
660
|
+
name: p.name,
|
|
661
|
+
kind: p.runs_where ?? "component",
|
|
662
|
+
purpose: null,
|
|
663
|
+
runsWhere: p.runs_where,
|
|
664
|
+
evidenceRef: p.evidence_ref,
|
|
665
|
+
moduleId: null,
|
|
666
|
+
status: "specified",
|
|
667
|
+
})),
|
|
668
|
+
edges: edges.map((e) => ({
|
|
669
|
+
id: `${e.from_piece_id}->${e.to_piece_id}`,
|
|
670
|
+
from_id: e.from_piece_id,
|
|
671
|
+
to_id: e.to_piece_id,
|
|
672
|
+
relationship: e.protocol ? `talks to over ${e.protocol}` : "talks to",
|
|
673
|
+
protocol: e.protocol,
|
|
674
|
+
})),
|
|
675
|
+
modules: modules.map((m) => ({
|
|
676
|
+
id: m.id,
|
|
677
|
+
name: m.name,
|
|
678
|
+
purpose: m.purpose,
|
|
679
|
+
status: m.status,
|
|
680
|
+
owns: m.owns_json ?? [],
|
|
681
|
+
consumes: m.consumes_json ?? [],
|
|
682
|
+
ownsEntityIds: entities.filter((e) => e.module_id === m.id).map((e) => e.id),
|
|
683
|
+
})),
|
|
684
|
+
moduleDependencies: dependencies,
|
|
685
|
+
criticalPath: criticalPath(modules, dependencies),
|
|
686
|
+
integrations: hydrateAll(
|
|
687
|
+
await db.all(
|
|
688
|
+
`SELECT * FROM integrations
|
|
689
|
+
WHERE module_id IN (SELECT id FROM modules WHERE project_id = ?) ORDER BY name`,
|
|
690
|
+
project.id,
|
|
691
|
+
),
|
|
692
|
+
),
|
|
693
|
+
layout: await layoutFor(db, project.id, "architecture"),
|
|
694
|
+
};
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const normalize = (value) => String(value ?? "").trim().toLowerCase();
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* A module dependency is not stored as an edge. It is implied: one module
|
|
702
|
+
* consumes what another owns. Deriving it keeps the two lists honest, because a
|
|
703
|
+
* consumed thing nobody owns shows up as an absent edge rather than as a lie.
|
|
704
|
+
*/
|
|
705
|
+
function moduleDependencies(modules) {
|
|
706
|
+
const owners = new Map();
|
|
707
|
+
for (const module of modules) {
|
|
708
|
+
for (const owned of module.owns_json ?? []) {
|
|
709
|
+
const key = normalize(owned);
|
|
710
|
+
if (key) owners.set(key, module.id);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const out = [];
|
|
714
|
+
const seen = new Set();
|
|
715
|
+
for (const module of modules) {
|
|
716
|
+
for (const consumed of module.consumes_json ?? []) {
|
|
717
|
+
const owner = owners.get(normalize(consumed));
|
|
718
|
+
if (!owner || owner === module.id) continue;
|
|
719
|
+
const key = `${module.id}->${owner}`;
|
|
720
|
+
if (seen.has(key)) continue;
|
|
721
|
+
seen.add(key);
|
|
722
|
+
out.push({
|
|
723
|
+
from_module_id: module.id,
|
|
724
|
+
to_module_id: owner,
|
|
725
|
+
reason: `${module.name} consumes ${consumed}, which ${
|
|
726
|
+
modules.find((m) => m.id === owner)?.name ?? owner
|
|
727
|
+
} owns.`,
|
|
728
|
+
critical: false,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The longest chain of dependent modules. Anything shorter can be built in
|
|
737
|
+
* parallel with it, so this is the ordering that actually bounds delivery.
|
|
738
|
+
* Cycles are broken rather than followed, because a dependency cycle is a
|
|
739
|
+
* modelling error that must not hang the request.
|
|
740
|
+
*/
|
|
741
|
+
function criticalPath(modules, dependencies) {
|
|
742
|
+
const out = new Map(modules.map((m) => [m.id, []]));
|
|
743
|
+
for (const dep of dependencies) out.get(dep.from_module_id)?.push(dep.to_module_id);
|
|
744
|
+
|
|
745
|
+
const memo = new Map();
|
|
746
|
+
const visiting = new Set();
|
|
747
|
+
const longest = (id) => {
|
|
748
|
+
if (memo.has(id)) return memo.get(id);
|
|
749
|
+
if (visiting.has(id)) return [id];
|
|
750
|
+
visiting.add(id);
|
|
751
|
+
let best = [];
|
|
752
|
+
for (const next of out.get(id) ?? []) {
|
|
753
|
+
const chain = longest(next);
|
|
754
|
+
if (chain.length > best.length) best = chain;
|
|
755
|
+
}
|
|
756
|
+
visiting.delete(id);
|
|
757
|
+
const path = [id, ...best];
|
|
758
|
+
memo.set(id, path);
|
|
759
|
+
return path;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
let winner = [];
|
|
763
|
+
for (const module of modules) {
|
|
764
|
+
const path = longest(module.id);
|
|
765
|
+
if (path.length > winner.length) winner = path;
|
|
766
|
+
}
|
|
767
|
+
// A single module is not a path worth drawing.
|
|
768
|
+
const chain = winner.length > 1 ? winner : [];
|
|
769
|
+
const onPath = new Set(chain.map((id, i) => `${id}->${chain[i + 1]}`));
|
|
770
|
+
for (const dep of dependencies) {
|
|
771
|
+
if (onPath.has(`${dep.from_module_id}->${dep.to_module_id}`)) dep.critical = true;
|
|
772
|
+
}
|
|
773
|
+
return chain;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// --------------------------------------------------------------------- tasks
|
|
777
|
+
|
|
778
|
+
export function tasks(root) {
|
|
779
|
+
return envelope(root, async (db, project) => {
|
|
780
|
+
const rows = hydrateAll(
|
|
781
|
+
await db.all("SELECT * FROM tasks WHERE project_id = ? ORDER BY sequence, id", project.id),
|
|
782
|
+
);
|
|
783
|
+
const ids = rows.map((t) => t.id);
|
|
784
|
+
|
|
785
|
+
const categories = await db.all(
|
|
786
|
+
"SELECT * FROM task_categories WHERE project_id = ? ORDER BY sequence, name",
|
|
787
|
+
project.id,
|
|
788
|
+
);
|
|
789
|
+
const contractLinks = ids.length
|
|
790
|
+
? await db.all(`SELECT * FROM task_contract_links WHERE task_id IN (${marks(ids.length)})`, ...ids)
|
|
791
|
+
: [];
|
|
792
|
+
const dependencies = ids.length
|
|
793
|
+
? await db.all(`SELECT * FROM task_dependencies WHERE task_id IN (${marks(ids.length)})`, ...ids)
|
|
794
|
+
: [];
|
|
795
|
+
const assignments = ids.length
|
|
796
|
+
? await db.all(
|
|
797
|
+
`SELECT * FROM task_assignments WHERE task_id IN (${marks(ids.length)}) ORDER BY assigned_at DESC`,
|
|
798
|
+
...ids,
|
|
799
|
+
)
|
|
800
|
+
: [];
|
|
801
|
+
const evidence = await db.all(
|
|
802
|
+
"SELECT * FROM verification_evidence WHERE project_id = ? ORDER BY recorded_at DESC",
|
|
803
|
+
project.id,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const featureName = new Map(
|
|
807
|
+
(await db.all("SELECT id, name FROM features WHERE project_id = ?", project.id)).map((f) => [
|
|
808
|
+
f.id,
|
|
809
|
+
f.name,
|
|
810
|
+
]),
|
|
811
|
+
);
|
|
812
|
+
const categoryName = new Map(categories.map((c) => [c.id, c.name]));
|
|
813
|
+
const activeAssignment = new Map(assignments.filter((a) => a.active).map((a) => [a.task_id, a]));
|
|
814
|
+
const children = new Map();
|
|
815
|
+
for (const task of rows) {
|
|
816
|
+
if (!task.parent_task_id) continue;
|
|
817
|
+
if (!children.has(task.parent_task_id)) children.set(task.parent_task_id, []);
|
|
818
|
+
children.get(task.parent_task_id).push(task.id);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
tasks: rows.map((task) => ({
|
|
823
|
+
...task,
|
|
824
|
+
featureName: featureName.get(task.feature_id) ?? null,
|
|
825
|
+
categoryName: categoryName.get(task.category_id) ?? null,
|
|
826
|
+
statusLabel: titleCase(task.status),
|
|
827
|
+
assignment: activeAssignment.get(task.id) ?? null,
|
|
828
|
+
subtaskIds: children.get(task.id) ?? [],
|
|
829
|
+
contractLinkCount: contractLinks.filter((l) => l.task_id === task.id).length,
|
|
830
|
+
})),
|
|
831
|
+
categories,
|
|
832
|
+
contractLinks,
|
|
833
|
+
dependencies,
|
|
834
|
+
assignments,
|
|
835
|
+
evidence,
|
|
836
|
+
developers: await db.all("SELECT * FROM developers WHERE project_id = ?", project.id),
|
|
837
|
+
agents: await db.all(
|
|
838
|
+
`SELECT * FROM agents WHERE developer_id IN (SELECT id FROM developers WHERE project_id = ?)`,
|
|
839
|
+
project.id,
|
|
840
|
+
),
|
|
841
|
+
branches: await db.all("SELECT * FROM branches WHERE project_id = ?", project.id),
|
|
842
|
+
};
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ---------------------------------------------------------------------- team
|
|
847
|
+
|
|
848
|
+
export function team(root) {
|
|
849
|
+
return envelope(root, async (db, project) => {
|
|
850
|
+
const developers = await db.all(
|
|
851
|
+
"SELECT * FROM developers WHERE project_id = ? ORDER BY display_name",
|
|
852
|
+
project.id,
|
|
853
|
+
);
|
|
854
|
+
const agents = await db.all(
|
|
855
|
+
`SELECT * FROM agents WHERE developer_id IN (SELECT id FROM developers WHERE project_id = ?)
|
|
856
|
+
ORDER BY last_seen_at DESC`,
|
|
857
|
+
project.id,
|
|
858
|
+
);
|
|
859
|
+
const branches = await db.all(
|
|
860
|
+
"SELECT * FROM branches WHERE project_id = ? ORDER BY last_seen_at DESC",
|
|
861
|
+
project.id,
|
|
862
|
+
);
|
|
863
|
+
const sessions = await db.all(
|
|
864
|
+
"SELECT * FROM work_sessions WHERE project_id = ? ORDER BY started_at DESC",
|
|
865
|
+
project.id,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const branchName = new Map(branches.map((b) => [b.id, b.name]));
|
|
869
|
+
const developerName = new Map(developers.map((d) => [d.id, d.display_name]));
|
|
870
|
+
const now = Date.now();
|
|
871
|
+
|
|
872
|
+
const presence = [];
|
|
873
|
+
const seen = new Set();
|
|
874
|
+
for (const session of sessions.filter((s) => s.status === "active")) {
|
|
875
|
+
const at = Date.parse(session.last_activity_at ?? session.started_at ?? "");
|
|
876
|
+
const kind = session.agent_id ? "agent" : "developer";
|
|
877
|
+
const actorId = session.agent_id ?? session.developer_id;
|
|
878
|
+
if (!actorId || seen.has(actorId)) continue;
|
|
879
|
+
seen.add(actorId);
|
|
880
|
+
const agent = agents.find((a) => a.id === session.agent_id);
|
|
881
|
+
presence.push({
|
|
882
|
+
actorId,
|
|
883
|
+
actorName:
|
|
884
|
+
kind === "agent"
|
|
885
|
+
? agent?.model_label ?? agent?.harness ?? actorId
|
|
886
|
+
: developerName.get(actorId) ?? actorId,
|
|
887
|
+
actorKind: kind,
|
|
888
|
+
presence: presenceLabel(now - at),
|
|
889
|
+
lastActivityAt: session.last_activity_at ?? session.started_at ?? null,
|
|
890
|
+
activeTaskId: session.active_task_id,
|
|
891
|
+
branchName: branchName.get(session.branch_id) ?? null,
|
|
892
|
+
sessionId: session.id,
|
|
893
|
+
objective: session.objective,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
developers,
|
|
899
|
+
agents,
|
|
900
|
+
branches,
|
|
901
|
+
sessions,
|
|
902
|
+
presence,
|
|
903
|
+
handoffs: sessions
|
|
904
|
+
.filter((s) => s.handoff)
|
|
905
|
+
.map((s) => ({
|
|
906
|
+
sessionId: s.id,
|
|
907
|
+
handoff: s.handoff,
|
|
908
|
+
nextAction: s.next_action,
|
|
909
|
+
outcome: s.outcome,
|
|
910
|
+
endedAt: s.ended_at,
|
|
911
|
+
})),
|
|
912
|
+
conflicts: hydrateAll(
|
|
913
|
+
await db.all(
|
|
914
|
+
"SELECT * FROM sync_conflicts WHERE project_id = ? AND status = 'open' ORDER BY detected_at DESC",
|
|
915
|
+
project.id,
|
|
916
|
+
),
|
|
917
|
+
),
|
|
918
|
+
};
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function presenceLabel(ageMs) {
|
|
923
|
+
if (!Number.isFinite(ageMs)) return "Offline";
|
|
924
|
+
if (ageMs < LIVE_WITHIN_MS) return "Live";
|
|
925
|
+
if (ageMs < SESSION_STALE_MS) return "Idle";
|
|
926
|
+
return "Offline";
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ----------------------------------------------------------------- decisions
|
|
930
|
+
|
|
931
|
+
export function decisions(root) {
|
|
932
|
+
return envelope(root, async (db, project) => {
|
|
933
|
+
const rows = hydrateAll(
|
|
934
|
+
await db.all("SELECT * FROM decisions WHERE project_id = ? ORDER BY created_at, id", project.id),
|
|
935
|
+
);
|
|
936
|
+
const ids = rows.map((d) => d.id);
|
|
937
|
+
const links = ids.length
|
|
938
|
+
? await db.all(`SELECT * FROM decision_links WHERE decision_id IN (${marks(ids.length)})`, ...ids)
|
|
939
|
+
: [];
|
|
940
|
+
const transitions = ids.length
|
|
941
|
+
? await db.all(
|
|
942
|
+
`SELECT * FROM decision_transitions WHERE decision_id IN (${marks(ids.length)})
|
|
943
|
+
ORDER BY decision_id, sequence`,
|
|
944
|
+
...ids,
|
|
945
|
+
)
|
|
946
|
+
: [];
|
|
947
|
+
|
|
948
|
+
return {
|
|
949
|
+
decisions: rows.map((d) => ({ ...d, statusLabel: titleCase(d.status) })),
|
|
950
|
+
links,
|
|
951
|
+
transitions,
|
|
952
|
+
conflicts: decisionConflicts(rows, links),
|
|
953
|
+
};
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* A conflict is a decision that is still being treated as binding while
|
|
959
|
+
* something about it has stopped being true. Each one names the decision, what
|
|
960
|
+
* is being asked of it, and where the clash is, so it can be acted on rather
|
|
961
|
+
* than merely noticed.
|
|
962
|
+
*/
|
|
963
|
+
function decisionConflicts(decisions, links) {
|
|
964
|
+
const now = Date.now();
|
|
965
|
+
const out = [];
|
|
966
|
+
const governing = new Map();
|
|
967
|
+
|
|
968
|
+
for (const decision of decisions) {
|
|
969
|
+
if (decision.status === "accepted" && decision.expires_at && Date.parse(decision.expires_at) <= now) {
|
|
970
|
+
out.push({
|
|
971
|
+
decisionId: decision.id,
|
|
972
|
+
request: "Continued reliance on a decision whose time box has run out",
|
|
973
|
+
explanation: `${decision.title} was time boxed to ${decision.expires_at} and is still marked accepted. Re-accept it or replace it.`,
|
|
974
|
+
targetType: decision.scope_type ?? null,
|
|
975
|
+
targetId: decision.scope_id ?? null,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
if (decision.status === "revisit_required") {
|
|
979
|
+
out.push({
|
|
980
|
+
decisionId: decision.id,
|
|
981
|
+
request: "Work continuing under a decision that asked to be revisited",
|
|
982
|
+
explanation: `${decision.title} is marked revisit required${
|
|
983
|
+
decision.revisit_triggers_json?.length ? `: ${decision.revisit_triggers_json.join("; ")}` : "."
|
|
984
|
+
}`,
|
|
985
|
+
targetType: decision.scope_type ?? null,
|
|
986
|
+
targetId: decision.scope_id ?? null,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
if (decision.status === "partially_superseded" && !decision.superseded_by_id) {
|
|
990
|
+
out.push({
|
|
991
|
+
decisionId: decision.id,
|
|
992
|
+
request: "Reading a partially superseded decision as if it were whole",
|
|
993
|
+
explanation: `${decision.title} is partially superseded but names no successor, so which part still governs is unrecorded.`,
|
|
994
|
+
targetType: decision.scope_type ?? null,
|
|
995
|
+
targetId: decision.scope_id ?? null,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
1001
|
+
for (const link of links) {
|
|
1002
|
+
if (link.relationship !== "governs") continue;
|
|
1003
|
+
if (byId.get(link.decision_id)?.status !== "accepted") continue;
|
|
1004
|
+
const key = `${link.target_type}:${link.target_id}`;
|
|
1005
|
+
if (!governing.has(key)) governing.set(key, []);
|
|
1006
|
+
governing.get(key).push(link.decision_id);
|
|
1007
|
+
}
|
|
1008
|
+
for (const [key, owners] of governing) {
|
|
1009
|
+
if (owners.length < 2) continue;
|
|
1010
|
+
const [type, id] = key.split(":");
|
|
1011
|
+
for (const decisionId of owners) {
|
|
1012
|
+
out.push({
|
|
1013
|
+
decisionId,
|
|
1014
|
+
request: `Two accepted decisions both govern ${id}`,
|
|
1015
|
+
explanation: `${owners.join(" and ")} each claim to govern ${type} ${id}. One of them has to supersede the other.`,
|
|
1016
|
+
targetType: type,
|
|
1017
|
+
targetId: id,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ------------------------------------------------------------------ activity
|
|
1025
|
+
|
|
1026
|
+
const ACTIVITY_PAGE = 100;
|
|
1027
|
+
const ACTIVITY_MAX_PAGE = 500;
|
|
1028
|
+
|
|
1029
|
+
/** `%` and `_` are wildcards, so a searched literal has to escape them. */
|
|
1030
|
+
const likeLiteral = (text) => text.replace(/[\\%_]/g, "\\$&");
|
|
1031
|
+
|
|
1032
|
+
export function activity(root, options = {}) {
|
|
1033
|
+
const before = Number.isFinite(options.before) && options.before > 0 ? Math.floor(options.before) : null;
|
|
1034
|
+
const limit = Math.min(
|
|
1035
|
+
Math.max(Number.isFinite(options.limit) ? Math.floor(options.limit) : ACTIVITY_PAGE, 1),
|
|
1036
|
+
ACTIVITY_MAX_PAGE,
|
|
1037
|
+
);
|
|
1038
|
+
const search = typeof options.search === "string" ? options.search.trim().slice(0, 200) : "";
|
|
1039
|
+
|
|
1040
|
+
return envelope(root, async (db, project) => {
|
|
1041
|
+
const where = ["project_id = ?"];
|
|
1042
|
+
const params = [project.id];
|
|
1043
|
+
if (before !== null) {
|
|
1044
|
+
where.push("sequence < ?");
|
|
1045
|
+
params.push(before);
|
|
1046
|
+
}
|
|
1047
|
+
if (search) {
|
|
1048
|
+
where.push("(summary LIKE ? ESCAPE '\\' OR event_type LIKE ? ESCAPE '\\')");
|
|
1049
|
+
params.push(`%${likeLiteral(search)}%`, `%${likeLiteral(search)}%`);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// One row over the page size answers hasMore without a second count query.
|
|
1053
|
+
const rows = await db.all(
|
|
1054
|
+
`SELECT * FROM activity_events WHERE ${where.join(" AND ")} ORDER BY sequence DESC LIMIT ?`,
|
|
1055
|
+
...params,
|
|
1056
|
+
limit + 1,
|
|
1057
|
+
);
|
|
1058
|
+
const hasMore = rows.length > limit;
|
|
1059
|
+
const events = hydrateAll(rows.slice(0, limit));
|
|
1060
|
+
|
|
1061
|
+
const actorNames = new Map(
|
|
1062
|
+
(await db.all("SELECT id, display_name FROM developers WHERE project_id = ?", project.id)).map(
|
|
1063
|
+
(d) => [d.id, d.display_name],
|
|
1064
|
+
),
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
const memory = hydrateAll(
|
|
1068
|
+
search
|
|
1069
|
+
? await db.all(
|
|
1070
|
+
`SELECT * FROM memory_entries
|
|
1071
|
+
WHERE project_id = ? AND superseded_by IS NULL
|
|
1072
|
+
AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')
|
|
1073
|
+
ORDER BY created_at DESC LIMIT ?`,
|
|
1074
|
+
project.id,
|
|
1075
|
+
`%${likeLiteral(search)}%`,
|
|
1076
|
+
`%${likeLiteral(search)}%`,
|
|
1077
|
+
limit,
|
|
1078
|
+
)
|
|
1079
|
+
: await db.all(
|
|
1080
|
+
`SELECT * FROM memory_entries WHERE project_id = ? AND superseded_by IS NULL
|
|
1081
|
+
ORDER BY created_at DESC LIMIT ?`,
|
|
1082
|
+
project.id,
|
|
1083
|
+
limit,
|
|
1084
|
+
),
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
return {
|
|
1088
|
+
events: events.map((e) => ({
|
|
1089
|
+
...e,
|
|
1090
|
+
actorName: actorNames.get(e.actor_id) ?? e.actor_label ?? null,
|
|
1091
|
+
typeLabel: titleCase(e.event_type),
|
|
1092
|
+
})),
|
|
1093
|
+
memory,
|
|
1094
|
+
sessions: await db.all(
|
|
1095
|
+
"SELECT * FROM work_sessions WHERE project_id = ? ORDER BY started_at DESC LIMIT 50",
|
|
1096
|
+
project.id,
|
|
1097
|
+
),
|
|
1098
|
+
page: {
|
|
1099
|
+
nextCursor: hasMore ? events.at(-1)?.sequence ?? null : null,
|
|
1100
|
+
hasMore,
|
|
1101
|
+
returned: events.length,
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ----------------------------------------------------------------- readiness
|
|
1108
|
+
|
|
1109
|
+
export function readinessView(root) {
|
|
1110
|
+
return envelope(root, async (db, project) => {
|
|
1111
|
+
const report = await readiness(db, project.id);
|
|
1112
|
+
const fresh = await freshness(db, project.id);
|
|
1113
|
+
const warnings = await alignmentWarnings(db, project.id);
|
|
1114
|
+
|
|
1115
|
+
const modules = await db.all(
|
|
1116
|
+
"SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, id",
|
|
1117
|
+
project.id,
|
|
1118
|
+
);
|
|
1119
|
+
const steps = modules.length
|
|
1120
|
+
? await db.all(
|
|
1121
|
+
`SELECT * FROM module_completeness WHERE module_id IN (${marks(modules.length)})
|
|
1122
|
+
ORDER BY module_id, step`,
|
|
1123
|
+
...modules.map((m) => m.id),
|
|
1124
|
+
)
|
|
1125
|
+
: [];
|
|
1126
|
+
const questions = hydrateAll(
|
|
1127
|
+
await db.all(
|
|
1128
|
+
"SELECT * FROM questions WHERE project_id = ? AND status IN ('open','deferred') ORDER BY created_at",
|
|
1129
|
+
project.id,
|
|
1130
|
+
),
|
|
1131
|
+
);
|
|
1132
|
+
const discoveryRisks = await db.all(
|
|
1133
|
+
"SELECT * FROM discovery_items WHERE project_id = ? AND kind = 'risk' AND status <> 'rejected'",
|
|
1134
|
+
project.id,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const moduleCompleteness = modules.map((module) => {
|
|
1138
|
+
const mine = steps.filter((s) => s.module_id === module.id);
|
|
1139
|
+
const byStep = new Map(mine.map((s) => [s.step, s]));
|
|
1140
|
+
const rendered = MODULE_STEPS.map((name, index) => {
|
|
1141
|
+
const step = byStep.get(index + 1);
|
|
1142
|
+
return {
|
|
1143
|
+
moduleId: module.id,
|
|
1144
|
+
step: index + 1,
|
|
1145
|
+
name: step?.step_name ?? name,
|
|
1146
|
+
state: step?.state ?? "open",
|
|
1147
|
+
summary: step?.summary ?? null,
|
|
1148
|
+
reason: step?.reason_not_applicable ?? null,
|
|
1149
|
+
};
|
|
1150
|
+
});
|
|
1151
|
+
const applicable = rendered.filter((s) => s.state !== "not_applicable");
|
|
1152
|
+
const filled = applicable.filter((s) => s.state === "filled").length;
|
|
1153
|
+
return {
|
|
1154
|
+
moduleId: module.id,
|
|
1155
|
+
moduleName: module.name,
|
|
1156
|
+
steps: rendered,
|
|
1157
|
+
progress: {
|
|
1158
|
+
completed: filled,
|
|
1159
|
+
total: applicable.length,
|
|
1160
|
+
percent: honestPercent(filled, applicable.length),
|
|
1161
|
+
measurable: applicable.length > 0,
|
|
1162
|
+
counts: `Module steps filled: ${filled} of ${applicable.length}.`,
|
|
1163
|
+
remains: applicable.length - filled
|
|
1164
|
+
? `${count(applicable.length - filled, "step")} still open.`
|
|
1165
|
+
: null,
|
|
1166
|
+
sourceRevision: report.sourceRevision,
|
|
1167
|
+
computedAt: report.generatedAt,
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
const risks = [
|
|
1173
|
+
...discoveryRisks.map((r) => ({
|
|
1174
|
+
id: r.id,
|
|
1175
|
+
statement: r.statement,
|
|
1176
|
+
severity: "Unassessed",
|
|
1177
|
+
mitigation: null,
|
|
1178
|
+
owner: null,
|
|
1179
|
+
source: "discovery",
|
|
1180
|
+
})),
|
|
1181
|
+
...warnings.map((w) => ({
|
|
1182
|
+
id: w.recordId ?? w.code,
|
|
1183
|
+
statement: `${w.title}: ${w.detail}`,
|
|
1184
|
+
severity: titleCase(w.severity),
|
|
1185
|
+
mitigation: w.remedy,
|
|
1186
|
+
owner: null,
|
|
1187
|
+
source: "alignment",
|
|
1188
|
+
})),
|
|
1189
|
+
];
|
|
1190
|
+
|
|
1191
|
+
const blockers = [];
|
|
1192
|
+
const unresolved = report.areas.filter(
|
|
1193
|
+
(a) => a.state === "awaiting_decision" || a.state === "deferred",
|
|
1194
|
+
);
|
|
1195
|
+
if (unresolved.length) {
|
|
1196
|
+
blockers.push(`${count(unresolved.length, "capability area")} ${agrees(unresolved.length, "has", "have")} no answer yet.`);
|
|
1197
|
+
}
|
|
1198
|
+
const silent = report.areas.filter((a) => a.silentGap);
|
|
1199
|
+
if (silent.length) {
|
|
1200
|
+
blockers.push(
|
|
1201
|
+
`${silent.length} of those have no owner and no open question, which is a silent gap.`,
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
const openSteps = moduleCompleteness.reduce(
|
|
1205
|
+
(n, m) => n + (m.progress.total - m.progress.completed),
|
|
1206
|
+
0,
|
|
1207
|
+
);
|
|
1208
|
+
if (openSteps) blockers.push(`${count(openSteps, "module completeness step")} ${agrees(openSteps, "is", "are")} still open.`);
|
|
1209
|
+
const open = questions.filter((q) => q.status === "open");
|
|
1210
|
+
if (open.length) blockers.push(`${count(open.length, "question")} are waiting on an answer.`);
|
|
1211
|
+
if (fresh.pendingDocumentProposals.length) {
|
|
1212
|
+
blockers.push(
|
|
1213
|
+
`${count(fresh.pendingDocumentProposals.length, "document")} were edited by hand and are unresolved.`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (fresh.staleEvidence.length) {
|
|
1217
|
+
blockers.push(`${count(fresh.staleEvidence.length, "verification record")} ${agrees(fresh.staleEvidence.length, "is", "are")} out of date.`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return {
|
|
1221
|
+
capabilityAreas: report.areas.map((a) => ({
|
|
1222
|
+
id: a.id,
|
|
1223
|
+
name: a.area,
|
|
1224
|
+
state: a.state,
|
|
1225
|
+
stateLabel: titleCase(a.state),
|
|
1226
|
+
choice: a.choice,
|
|
1227
|
+
reason: a.reason,
|
|
1228
|
+
owner: a.owner,
|
|
1229
|
+
revisitTrigger: a.revisitTrigger,
|
|
1230
|
+
consequence: a.consequence,
|
|
1231
|
+
decisionId: a.decisionId,
|
|
1232
|
+
silentGap: a.silentGap,
|
|
1233
|
+
questionIds: a.questionId ? [a.questionId] : [],
|
|
1234
|
+
})),
|
|
1235
|
+
areaCounts: report.areaCounts,
|
|
1236
|
+
modules: moduleCompleteness,
|
|
1237
|
+
openQuestions: questions,
|
|
1238
|
+
risks,
|
|
1239
|
+
releaseReadiness: {
|
|
1240
|
+
verdict: !report.measurable
|
|
1241
|
+
? "Not measurable"
|
|
1242
|
+
: blockers.length
|
|
1243
|
+
? "Not ready to release"
|
|
1244
|
+
: "Ready to release",
|
|
1245
|
+
explanation: !report.measurable
|
|
1246
|
+
? "No readiness checklist has been recorded, so this cannot be answered yet. Run discovery to evaluate the capability areas."
|
|
1247
|
+
: blockers.length
|
|
1248
|
+
? "Everything below has to be closed or explicitly accepted before this can be called ready."
|
|
1249
|
+
: "Every capability area is answered, every module step is filled or explicitly not applicable, and no question or document edit is outstanding.",
|
|
1250
|
+
blockers,
|
|
1251
|
+
},
|
|
1252
|
+
progress: toProgress(report),
|
|
1253
|
+
};
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The interface a person touches, and the actions on it.
|
|
1259
|
+
*
|
|
1260
|
+
* Section 16.1 requires a UI Surfaces area. The records existed and nothing
|
|
1261
|
+
* served them, so twenty surfaces and their actions were in the database and
|
|
1262
|
+
* reachable from nowhere.
|
|
1263
|
+
*/
|
|
1264
|
+
async function surfacesView(root) {
|
|
1265
|
+
return envelope(root, async (db, project) => {
|
|
1266
|
+
const surfaces = await db.all(
|
|
1267
|
+
`SELECT s.*, m.name AS module_name, f.name AS feature_name
|
|
1268
|
+
FROM surfaces s
|
|
1269
|
+
LEFT JOIN modules m ON m.id = s.module_id
|
|
1270
|
+
LEFT JOIN features f ON f.id = s.feature_id
|
|
1271
|
+
ORDER BY s.name`);
|
|
1272
|
+
const actions = surfaces.length
|
|
1273
|
+
? await db.all(
|
|
1274
|
+
`SELECT * FROM ui_actions WHERE surface_id IN (${marks(surfaces.length)}) ORDER BY name`,
|
|
1275
|
+
...surfaces.map((s) => s.id))
|
|
1276
|
+
: [];
|
|
1277
|
+
const bySurface = new Map();
|
|
1278
|
+
for (const a of actions) {
|
|
1279
|
+
if (!bySurface.has(a.surface_id)) bySurface.set(a.surface_id, []);
|
|
1280
|
+
bySurface.get(a.surface_id).push(a);
|
|
1281
|
+
}
|
|
1282
|
+
return {
|
|
1283
|
+
surfaces: surfaces.map((s) => ({
|
|
1284
|
+
...s,
|
|
1285
|
+
entities_shown_json: json(s.entities_shown_json, []),
|
|
1286
|
+
components_json: json(s.components_json, []),
|
|
1287
|
+
actions: bySurface.get(s.id) ?? [],
|
|
1288
|
+
})),
|
|
1289
|
+
counts: { surfaces: surfaces.length, actions: actions.length },
|
|
1290
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* The API surface: every operation and the boundary that groups it.
|
|
1296
|
+
*
|
|
1297
|
+
* Section 16.1 requires an APIs area. Seventy operations were recorded and the
|
|
1298
|
+
* control centre showed none of them.
|
|
1299
|
+
*/
|
|
1300
|
+
async function apisView(root) {
|
|
1301
|
+
return envelope(root, async (db, project) => {
|
|
1302
|
+
const services = await db.all(
|
|
1303
|
+
`SELECT s.*, m.name AS module_name FROM api_services s
|
|
1304
|
+
LEFT JOIN modules m ON m.id = s.module_id ORDER BY s.sequence, s.id`);
|
|
1305
|
+
const operations = await db.all(
|
|
1306
|
+
`SELECT o.*, m.name AS module_name, f.name AS feature_name
|
|
1307
|
+
FROM api_operations o
|
|
1308
|
+
LEFT JOIN modules m ON m.id = o.module_id
|
|
1309
|
+
LEFT JOIN features f ON f.id = o.feature_id
|
|
1310
|
+
ORDER BY o.name`);
|
|
1311
|
+
return {
|
|
1312
|
+
services,
|
|
1313
|
+
operations: operations.map((o) => ({
|
|
1314
|
+
...o,
|
|
1315
|
+
side_effects_json: json(o.side_effects_json, []),
|
|
1316
|
+
changesState: json(o.side_effects_json, []).length > 0,
|
|
1317
|
+
})),
|
|
1318
|
+
counts: {
|
|
1319
|
+
services: services.length,
|
|
1320
|
+
operations: operations.length,
|
|
1321
|
+
// An operation with no service is loose: nothing says which boundary
|
|
1322
|
+
// owns it, which is the gap section 6.1 draws between the two objects.
|
|
1323
|
+
ungrouped: operations.filter((o) => !o.api_service_id).length,
|
|
1324
|
+
},
|
|
1325
|
+
};
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* What moved in accepted scope, and why.
|
|
1331
|
+
*
|
|
1332
|
+
* Section 16.1 requires a Changes area and 16.3 requires navigating from a
|
|
1333
|
+
* change to the records it affected.
|
|
1334
|
+
*/
|
|
1335
|
+
async function changesView(root) {
|
|
1336
|
+
return envelope(root, async (db, project) => {
|
|
1337
|
+
const changes = await db.all(
|
|
1338
|
+
`SELECT c.*, d.title AS decision_title FROM changes c
|
|
1339
|
+
LEFT JOIN decisions d ON d.id = c.decision_id
|
|
1340
|
+
ORDER BY c.created_at DESC, c.id DESC LIMIT 200`);
|
|
1341
|
+
const targets = changes.length
|
|
1342
|
+
? await db.all(
|
|
1343
|
+
`SELECT * FROM change_targets WHERE change_id IN (${marks(changes.length)})`,
|
|
1344
|
+
...changes.map((c) => c.id))
|
|
1345
|
+
: [];
|
|
1346
|
+
const byChange = new Map();
|
|
1347
|
+
for (const t of targets) {
|
|
1348
|
+
if (!byChange.has(t.change_id)) byChange.set(t.change_id, []);
|
|
1349
|
+
byChange.get(t.change_id).push(t);
|
|
1350
|
+
}
|
|
1351
|
+
const assumptions = await db.all("SELECT * FROM assumptions ORDER BY CASE status WHEN 'holding' THEN 0 ELSE 1 END, created_at DESC");
|
|
1352
|
+
return {
|
|
1353
|
+
changes: changes.map((c) => ({ ...c, targets: byChange.get(c.id) ?? [] })),
|
|
1354
|
+
assumptions,
|
|
1355
|
+
counts: {
|
|
1356
|
+
changes: changes.length,
|
|
1357
|
+
assumptions: assumptions.length,
|
|
1358
|
+
holding: assumptions.filter((a) => a.status === "holding").length,
|
|
1359
|
+
},
|
|
1360
|
+
};
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* What proves the product works.
|
|
1366
|
+
*
|
|
1367
|
+
* Section 16.1 requires an Evidence area, and 16.3 requires navigating from a
|
|
1368
|
+
* task to its evidence. Evidence was only ever visible inside one task at a
|
|
1369
|
+
* time, so nobody could see how much of the product was actually proven.
|
|
1370
|
+
*/
|
|
1371
|
+
async function evidenceView(root) {
|
|
1372
|
+
return envelope(root, async (db, project) => {
|
|
1373
|
+
const evidence = await db.all(
|
|
1374
|
+
`SELECT e.*, t.name AS task_name, t.status AS task_status, f.name AS feature_name
|
|
1375
|
+
FROM verification_evidence e
|
|
1376
|
+
LEFT JOIN tasks t ON t.id = e.task_id
|
|
1377
|
+
LEFT JOIN features f ON f.id = e.feature_id
|
|
1378
|
+
ORDER BY e.recorded_at DESC LIMIT 300`);
|
|
1379
|
+
const criteria = await db.all(
|
|
1380
|
+
`SELECT c.*, f.name AS feature_name FROM feature_acceptance_criteria c
|
|
1381
|
+
LEFT JOIN features f ON f.id = c.feature_id ORDER BY c.feature_id, c.sequence`);
|
|
1382
|
+
const plans = await db.all(
|
|
1383
|
+
`SELECT p.*, f.name AS feature_name FROM test_plans p
|
|
1384
|
+
LEFT JOIN features f ON f.id = p.feature_id ORDER BY p.id`);
|
|
1385
|
+
const cases = plans.length
|
|
1386
|
+
? await db.all(`SELECT * FROM test_plan_cases WHERE test_plan_id IN (${marks(plans.length)}) ORDER BY sequence`,
|
|
1387
|
+
...plans.map((p) => p.id))
|
|
1388
|
+
: [];
|
|
1389
|
+
const current = evidence.filter((e) => e.status === "current");
|
|
1390
|
+
return {
|
|
1391
|
+
evidence,
|
|
1392
|
+
criteria,
|
|
1393
|
+
testPlans: plans.map((p) => ({ ...p, cases: cases.filter((c) => c.test_plan_id === p.id) })),
|
|
1394
|
+
counts: {
|
|
1395
|
+
recorded: evidence.length,
|
|
1396
|
+
current: current.length,
|
|
1397
|
+
passing: current.filter((e) => e.result === "pass").length,
|
|
1398
|
+
failing: current.filter((e) => e.result === "fail").length,
|
|
1399
|
+
inconclusive: current.filter((e) => e.result === "inconclusive").length,
|
|
1400
|
+
stale: evidence.filter((e) => e.status === "stale").length,
|
|
1401
|
+
// Evidence that can be run again is the only kind that stays true on
|
|
1402
|
+
// its own. The rest is a sentence somebody wrote once.
|
|
1403
|
+
reRunnable: current.filter((e) => e.check_command).length,
|
|
1404
|
+
criteriaMet: criteria.filter((c) => c.status === "met").length,
|
|
1405
|
+
criteriaTotal: criteria.length,
|
|
1406
|
+
},
|
|
1407
|
+
};
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* What this installation is and how it is configured.
|
|
1413
|
+
*
|
|
1414
|
+
* Section 16.1 requires a Settings area. Nothing here is editable from the
|
|
1415
|
+
* browser: these are facts about the machine and the project, and changing them
|
|
1416
|
+
* belongs to the commands that own them.
|
|
1417
|
+
*/
|
|
1418
|
+
async function settingsView(root) {
|
|
1419
|
+
return envelope(root, async (db, project) => {
|
|
1420
|
+
const migrations = await db.all("SELECT version, name, applied_at FROM applied_migrations ORDER BY version");
|
|
1421
|
+
const categories = await db.all("SELECT * FROM task_categories ORDER BY sequence, id").catch(() => []);
|
|
1422
|
+
const counts = {};
|
|
1423
|
+
for (const table of ["goals", "milestones", "modules", "features", "workflows", "tasks",
|
|
1424
|
+
"data_entities", "api_operations", "surfaces", "integrations",
|
|
1425
|
+
"decisions", "questions", "assumptions", "changes",
|
|
1426
|
+
"memory_entries", "verification_evidence", "activity_events", "documents"]) {
|
|
1427
|
+
counts[table] = (await db.get(`SELECT COUNT(*) n FROM ${table}`).catch(() => ({ n: 0 }))).n;
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
project,
|
|
1431
|
+
schema: {
|
|
1432
|
+
version: migrations.at(-1)?.version ?? 0,
|
|
1433
|
+
applied: migrations.length,
|
|
1434
|
+
migrations,
|
|
1435
|
+
},
|
|
1436
|
+
categories,
|
|
1437
|
+
counts,
|
|
1438
|
+
storage: {
|
|
1439
|
+
// Where things live, so a person can find them without reading source.
|
|
1440
|
+
database: ".superdev/superdev.db",
|
|
1441
|
+
backups: ".superdev/backups",
|
|
1442
|
+
exports: ".superdev/exports",
|
|
1443
|
+
documents: "talks",
|
|
1444
|
+
},
|
|
1445
|
+
};
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Test plans: the agreed verification strategy, and the cases inside it.
|
|
1451
|
+
*
|
|
1452
|
+
* Section 6.1 defines a test plan and 9.3 gates task completion on the tests it
|
|
1453
|
+
* defines. It had no route of its own, so the strategy a task is judged against
|
|
1454
|
+
* was only visible bundled inside the evidence payload.
|
|
1455
|
+
*/
|
|
1456
|
+
/**
|
|
1457
|
+
* Synchronization, as it actually stands.
|
|
1458
|
+
*
|
|
1459
|
+
* Two questions matter here and both are easy to answer wrongly by omission:
|
|
1460
|
+
* what would leave this machine, and what never does. A page that showed only
|
|
1461
|
+
* a connection state would let a reader assume the answer to the second, and
|
|
1462
|
+
* section 18 makes that assumption the expensive kind.
|
|
1463
|
+
*/
|
|
1464
|
+
async function syncView(root) {
|
|
1465
|
+
return envelope(root, async (db, project) => {
|
|
1466
|
+
const { SHARED_TABLES, WITHHELD } = await import("../cloud/policy.mjs");
|
|
1467
|
+
const peer = await db.get("SELECT * FROM sync_peers ORDER BY connected_at DESC LIMIT 1");
|
|
1468
|
+
const conflicts = await db.all(
|
|
1469
|
+
"SELECT * FROM sync_conflicts ORDER BY status, detected_at DESC LIMIT 100").catch(() => []);
|
|
1470
|
+
const leases = await db.all(
|
|
1471
|
+
`SELECT a.task_id, a.lease_holder, a.lease_expires_at, a.origin_peer, t.name AS task_name
|
|
1472
|
+
FROM task_assignments a LEFT JOIN tasks t ON t.id = a.task_id
|
|
1473
|
+
WHERE a.active = 1 AND a.lease_holder IS NOT NULL`).catch(() => []);
|
|
1474
|
+
const tracked = await db.get(
|
|
1475
|
+
"SELECT COUNT(*) AS n FROM sync_base WHERE peer_id = ?", peer?.id ?? "").catch(() => ({ n: 0 }));
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
peer: peer ?? null,
|
|
1479
|
+
conflicts,
|
|
1480
|
+
leases,
|
|
1481
|
+
trackedRecords: Number(tracked?.n ?? 0),
|
|
1482
|
+
shared: SHARED_TABLES,
|
|
1483
|
+
withheld: Object.entries(WITHHELD).map(([table, reason]) => ({ table, reason })),
|
|
1484
|
+
counts: {
|
|
1485
|
+
shared: SHARED_TABLES.length,
|
|
1486
|
+
withheld: Object.keys(WITHHELD).length,
|
|
1487
|
+
openConflicts: conflicts.filter((c) => c.status === "open").length,
|
|
1488
|
+
leases: leases.length,
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function testPlansView(root) {
|
|
1495
|
+
return envelope(root, async (db, project) => {
|
|
1496
|
+
const plans = await db.all(
|
|
1497
|
+
`SELECT p.*, f.name AS feature_name, w.name AS workflow_name, m.name AS module_name,
|
|
1498
|
+
(SELECT COUNT(*) FROM verification_evidence e
|
|
1499
|
+
WHERE e.test_plan_id = p.id AND e.status = 'current' AND e.result = 'pass'
|
|
1500
|
+
AND (e.last_check_result IS NULL OR e.last_check_result = 'pass')) AS passing_runs,
|
|
1501
|
+
(SELECT e.summary FROM verification_evidence e
|
|
1502
|
+
WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_summary,
|
|
1503
|
+
(SELECT e.recorded_at FROM verification_evidence e
|
|
1504
|
+
WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_at,
|
|
1505
|
+
(SELECT e.result FROM verification_evidence e
|
|
1506
|
+
WHERE e.test_plan_id = p.id ORDER BY e.recorded_at DESC LIMIT 1) AS last_run_result
|
|
1507
|
+
FROM test_plans p
|
|
1508
|
+
LEFT JOIN features f ON f.id = p.feature_id
|
|
1509
|
+
LEFT JOIN workflows w ON w.id = p.workflow_id
|
|
1510
|
+
LEFT JOIN modules m ON m.id = p.module_id
|
|
1511
|
+
ORDER BY p.id`);
|
|
1512
|
+
const cases = plans.length
|
|
1513
|
+
? await db.all(`SELECT * FROM test_plan_cases WHERE test_plan_id IN (${marks(plans.length)}) ORDER BY sequence`,
|
|
1514
|
+
...plans.map((p) => p.id))
|
|
1515
|
+
: [];
|
|
1516
|
+
return {
|
|
1517
|
+
testPlans: plans.map((p) => ({
|
|
1518
|
+
...p,
|
|
1519
|
+
passing_runs: Number(p.passing_runs ?? 0),
|
|
1520
|
+
// Whether the plan has been run is the question section 9.3 makes it
|
|
1521
|
+
// answer, and the reason a task it covers can or cannot complete.
|
|
1522
|
+
satisfied: Number(p.passing_runs ?? 0) > 0,
|
|
1523
|
+
cases: cases.filter((c) => c.test_plan_id === p.id),
|
|
1524
|
+
})),
|
|
1525
|
+
counts: {
|
|
1526
|
+
plans: plans.length,
|
|
1527
|
+
accepted: plans.filter((p) => p.status === "accepted").length,
|
|
1528
|
+
unrun: plans.filter((p) => p.status === "accepted" && !Number(p.passing_runs ?? 0)).length,
|
|
1529
|
+
cases: cases.length,
|
|
1530
|
+
// A case with a command can be run by anyone. One without is a check
|
|
1531
|
+
// somebody performs, which is honest but not automatic.
|
|
1532
|
+
runnable: cases.filter((c) => c.command).length,
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** Route table, so the server maps a path to a builder without a switch. */
|
|
1539
|
+
export const routes = {
|
|
1540
|
+
"/api/test-plans": testPlansView,
|
|
1541
|
+
"/api/sync": syncView,
|
|
1542
|
+
"/api/surfaces": surfacesView,
|
|
1543
|
+
"/api/apis": apisView,
|
|
1544
|
+
"/api/changes": changesView,
|
|
1545
|
+
"/api/evidence": evidenceView,
|
|
1546
|
+
"/api/settings": settingsView,
|
|
1547
|
+
"/api/overview": overview,
|
|
1548
|
+
"/api/discovery": discovery,
|
|
1549
|
+
"/api/product": product,
|
|
1550
|
+
"/api/workflows": workflows,
|
|
1551
|
+
"/api/data": data,
|
|
1552
|
+
"/api/architecture": architecture,
|
|
1553
|
+
"/api/tasks": tasks,
|
|
1554
|
+
"/api/team": team,
|
|
1555
|
+
"/api/decisions": decisions,
|
|
1556
|
+
"/api/readiness": readinessView,
|
|
1557
|
+
};
|