ndomo 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/.bun-version +1 -0
- package/.dockerignore +79 -0
- package/.editorconfig +18 -0
- package/.env.example +19 -0
- package/.github/CODEOWNERS +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +2 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/dependabot.yml +36 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/release.yml +30 -0
- package/.github/workflows/gitleaks.yml +28 -0
- package/.github/workflows/release-please.yml +27 -0
- package/.github/workflows/smoke.yml +29 -0
- package/.husky/commit-msg +1 -0
- package/CHANGELOG.md +114 -0
- package/Dockerfile +32 -0
- package/README.es.md +174 -0
- package/README.md +187 -0
- package/agents/chronicler.md +98 -0
- package/agents/ci-smith.md +136 -0
- package/agents/craftsman.md +341 -0
- package/agents/deploy-smith.md +138 -0
- package/agents/foreman.md +377 -0
- package/agents/go-smith.md +164 -0
- package/agents/guild.md +188 -0
- package/agents/inspector.md +83 -0
- package/agents/js-smith.md +127 -0
- package/agents/ops-scout.md +173 -0
- package/agents/painter.md +200 -0
- package/agents/python-smith.md +120 -0
- package/agents/ranger.md +307 -0
- package/agents/release-smith.md +165 -0
- package/agents/rust-smith.md +159 -0
- package/agents/sage.md +178 -0
- package/agents/scout.md +144 -0
- package/agents/scribe.md +156 -0
- package/agents/smith.md +201 -0
- package/agents/vue-smith.md +155 -0
- package/agents/warden.md +216 -0
- package/agents/zig-smith.md +156 -0
- package/bin/ndomo-analyses.ts +4 -0
- package/bin/ndomo-status.ts +4 -0
- package/biome.json +57 -0
- package/bun.lock +514 -0
- package/commitlint.config.js +3 -0
- package/config/ndomo.config.json +258 -0
- package/config/ndomo.schema.json +166 -0
- package/docs/agents.md +375 -0
- package/docs/bugs/plan-create-orphan-fk.md +131 -0
- package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
- package/docs/configuration.md +276 -0
- package/docs/database.md +364 -0
- package/docs/features/feature-flexible-builder-v1.md +724 -0
- package/docs/features/feature-flexible-builder-v2.md +882 -0
- package/docs/features/feature-flexible-builder.md +974 -0
- package/docs/http-server.md +244 -0
- package/docs/installation.md +259 -0
- package/docs/integrations.md +129 -0
- package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
- package/docs/operations/audit-v1.md +417 -0
- package/docs/operations/audit-v2.md +197 -0
- package/docs/operations/audit-v3.md +306 -0
- package/docs/operations/db-optimize-foundations.md +123 -0
- package/docs/operations/verify-gate-architecture.md +82 -0
- package/docs/workflows.md +448 -0
- package/opencode.json +5 -0
- package/package.json +65 -0
- package/release-please-config.json +11 -0
- package/scripts/dev-bust-cache.sh +164 -0
- package/scripts/install.sh +688 -0
- package/scripts/smoke-e2e.ts +704 -0
- package/scripts/smoke-hot.ts +417 -0
- package/scripts/smoke-http.sh +228 -0
- package/scripts/smoke-v4.ts +256 -0
- package/scripts/smoke-v5.ts +397 -0
- package/scripts/smoke.sh +9 -0
- package/scripts/uninstall.sh +224 -0
- package/skills/api-security-best-practices/SKILL.md +915 -0
- package/skills/bash-scripting/SKILL.md +201 -0
- package/skills/bun/SKILL.md +313 -0
- package/skills/cavecrew/SKILL.md +82 -0
- package/skills/caveman/SKILL.md +74 -0
- package/skills/caveman-review/README.md +33 -0
- package/skills/caveman-review/SKILL.md +55 -0
- package/skills/find-skills/SKILL.md +142 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +55 -0
- package/skills/golang-patterns/SKILL.md +674 -0
- package/skills/golang-security/SKILL.md +185 -0
- package/skills/golang-security/evals/evals.json +595 -0
- package/skills/golang-security/references/architecture.md +268 -0
- package/skills/golang-security/references/checklist.md +80 -0
- package/skills/golang-security/references/cookies.md +200 -0
- package/skills/golang-security/references/cryptography.md +424 -0
- package/skills/golang-security/references/filesystem.md +285 -0
- package/skills/golang-security/references/injection.md +315 -0
- package/skills/golang-security/references/logging.md +163 -0
- package/skills/golang-security/references/memory-safety.md +241 -0
- package/skills/golang-security/references/network.md +253 -0
- package/skills/golang-security/references/secrets.md +189 -0
- package/skills/golang-security/references/third-party.md +159 -0
- package/skills/golang-security/references/threat-modeling.md +189 -0
- package/skills/golang-testing/SKILL.md +720 -0
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/javascript-testing-patterns/SKILL.md +537 -0
- package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
- package/skills/modern-javascript-patterns/SKILL.md +43 -0
- package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
- package/skills/modern-javascript-patterns/references/details.md +457 -0
- package/skills/python-anti-patterns/SKILL.md +349 -0
- package/skills/python-design-patterns/SKILL.md +85 -0
- package/skills/python-design-patterns/references/details.md +353 -0
- package/skills/python-error-handling/SKILL.md +193 -0
- package/skills/python-error-handling/references/details.md +171 -0
- package/skills/python-testing-patterns/SKILL.md +278 -0
- package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
- package/skills/python-testing-patterns/references/details.md +349 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-review/cloud-infrastructure-security.md +361 -0
- package/skills/vue-best-practices/SKILL.md +154 -0
- package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
- package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
- package/skills/vue-best-practices/references/component-async.md +97 -0
- package/skills/vue-best-practices/references/component-data-flow.md +307 -0
- package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
- package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
- package/skills/vue-best-practices/references/component-slots.md +216 -0
- package/skills/vue-best-practices/references/component-suspense.md +228 -0
- package/skills/vue-best-practices/references/component-teleport.md +108 -0
- package/skills/vue-best-practices/references/component-transition-group.md +128 -0
- package/skills/vue-best-practices/references/component-transition.md +125 -0
- package/skills/vue-best-practices/references/composables.md +290 -0
- package/skills/vue-best-practices/references/directives.md +162 -0
- package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
- package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
- package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
- package/skills/vue-best-practices/references/plugins.md +166 -0
- package/skills/vue-best-practices/references/reactivity.md +344 -0
- package/skills/vue-best-practices/references/render-functions.md +201 -0
- package/skills/vue-best-practices/references/sfc.md +310 -0
- package/skills/vue-best-practices/references/state-management.md +135 -0
- package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
- package/skills/vue-pinia-best-practices/SKILL.md +21 -0
- package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
- package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
- package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
- package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
- package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
- package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
- package/skills/zig-0.16/SKILL.md +840 -0
- package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
- package/src/cli/analyses.ts +280 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/serve.ts +192 -0
- package/src/cli/smoke.ts +131 -0
- package/src/cli/status.test.ts +204 -0
- package/src/cli/status.ts +263 -0
- package/src/cli/vacuum.test.ts +82 -0
- package/src/cli/vacuum.ts +96 -0
- package/src/config/schema.test.ts +88 -0
- package/src/config/schema.ts +64 -0
- package/src/db/analyses-migration.test.ts +210 -0
- package/src/db/analyses.test.ts +466 -0
- package/src/db/analyses.ts +375 -0
- package/src/db/auto-checkpoint.ts +131 -0
- package/src/db/client.test.ts +129 -0
- package/src/db/client.ts +55 -0
- package/src/db/fts-escape.ts +20 -0
- package/src/db/incidents.test.ts +201 -0
- package/src/db/incidents.ts +93 -0
- package/src/db/index.ts +86 -0
- package/src/db/migrations-v13.test.ts +141 -0
- package/src/db/migrations-v8.test.ts +301 -0
- package/src/db/migrations.ts +147 -0
- package/src/db/plan-archive.test.ts +180 -0
- package/src/db/plan-archive.ts +274 -0
- package/src/db/plan-create.test.ts +276 -0
- package/src/db/plan-create.ts +78 -0
- package/src/db/plan-files.test.ts +289 -0
- package/src/db/plan-update-status.ts +287 -0
- package/src/db/plans.test.ts +490 -0
- package/src/db/plans.ts +534 -0
- package/src/db/resolve-project-dir.test.ts +143 -0
- package/src/db/resolve-project-dir.ts +75 -0
- package/src/db/rollbacks.test.ts +150 -0
- package/src/db/rollbacks.ts +67 -0
- package/src/db/schema.ts +907 -0
- package/src/db/sessions.test.ts +80 -0
- package/src/db/sessions.ts +135 -0
- package/src/db/shutdown.test.ts +147 -0
- package/src/db/shutdown.ts +45 -0
- package/src/db/tasks.test.ts +921 -0
- package/src/db/tasks.ts +747 -0
- package/src/db/types.ts +619 -0
- package/src/http/__tests__/auth.test.ts +196 -0
- package/src/http/__tests__/routes.test.ts +465 -0
- package/src/http/__tests__/sse.test.ts +317 -0
- package/src/http/auth.ts +72 -0
- package/src/http/middleware/cors.ts +53 -0
- package/src/http/middleware/security-headers.ts +21 -0
- package/src/http/routes/events.ts +112 -0
- package/src/http/routes/health.ts +51 -0
- package/src/http/routes/plans.ts +66 -0
- package/src/http/routes/sessions.ts +50 -0
- package/src/http/routes/tasks.ts +60 -0
- package/src/http/server.ts +95 -0
- package/src/http/sse.ts +116 -0
- package/src/index.ts +37 -0
- package/src/lib.ts +65 -0
- package/src/mem/scoped.ts +65 -0
- package/src/orchestrator/background.test.ts +268 -0
- package/src/orchestrator/background.ts +293 -0
- package/src/orchestrator/memory-hook.ts +182 -0
- package/src/orchestrator/reconciler.ts +123 -0
- package/src/orchestrator/scheduler.test.ts +300 -0
- package/src/orchestrator/scheduler.ts +243 -0
- package/src/plugin.test.ts +2574 -0
- package/src/plugin.ts +1690 -0
- package/src/sdk/client.ts +66 -0
- package/src/worktrees/manager.ts +236 -0
- package/src/worktrees/state.ts +87 -0
- package/tests/integration/ranger-flow.test.ts +257 -0
- package/tools/analysis_archive.ts +28 -0
- package/tools/analysis_create.ts +55 -0
- package/tools/analysis_get.ts +33 -0
- package/tools/analysis_link_plan.ts +44 -0
- package/tools/analysis_list.ts +48 -0
- package/tools/analysis_search.ts +36 -0
- package/tools/analysis_update.ts +44 -0
- package/tools/plan_approve.ts +31 -0
- package/tools/plan_create.ts +58 -0
- package/tools/plan_get.ts +40 -0
- package/tools/plan_list.ts +37 -0
- package/tools/plan_search.ts +34 -0
- package/tools/plan_update_status.ts +71 -0
- package/tools/session_checkpoint.ts +31 -0
- package/tools/session_end.ts +26 -0
- package/tools/session_start.ts +43 -0
- package/tools/task_create_batch.ts +70 -0
- package/tools/task_list.ts +35 -0
- package/tools/task_next_for_agent.ts +30 -0
- package/tools/task_search.ts +34 -0
- package/tools/task_update_status.ts +37 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — FTS5 query escaping.
|
|
3
|
+
*
|
|
4
|
+
* FTS5 MATCH interprets special characters as syntax:
|
|
5
|
+
* " - ( ) : *
|
|
6
|
+
* A raw user input like "auth-bug" causes FTS5 to parse "-bug" as a column
|
|
7
|
+
* qualifier, throwing "SQLiteError: no such column: bug".
|
|
8
|
+
*
|
|
9
|
+
* The safe fix is to wrap the query in double quotes, making FTS5 treat the
|
|
10
|
+
* entire string as a literal phrase. Internal double quotes are escaped by
|
|
11
|
+
* doubling them ("").
|
|
12
|
+
*
|
|
13
|
+
* NOTE: Wrapped queries are phrase queries — FTS5 does NOT tokenize the
|
|
14
|
+
* interior. This is correct for our use case (exact phrase with hyphens).
|
|
15
|
+
* If boolean queries (AND/OR/NOT) are needed in the future, use selective
|
|
16
|
+
* escaping instead of blanket wrapping.
|
|
17
|
+
*/
|
|
18
|
+
export function escapeFtsQuery(q: string): string {
|
|
19
|
+
return `"${q.replace(/"/g, '""')}"`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test"
|
|
3
|
+
import { createIncident, getIncident, listIncidents, updateIncidentStatus } from "./incidents.ts"
|
|
4
|
+
import { runMigrations } from "./migrations.ts"
|
|
5
|
+
|
|
6
|
+
function createTestDeployment(db: Database): string {
|
|
7
|
+
const now = Date.now()
|
|
8
|
+
db.query("INSERT INTO environments (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run("e1", "prod", "prod", now, now)
|
|
9
|
+
db.query("INSERT INTO releases (id, version, title, created_at) VALUES (?, ?, ?, ?)").run("r1", "1.0.0", "rel", now)
|
|
10
|
+
db.query("INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)").run("d1", "r1", "e1", "planned", now)
|
|
11
|
+
return "d1"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("incidents.ts", () => {
|
|
15
|
+
let db: Database
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(":memory:")
|
|
18
|
+
db.exec("PRAGMA foreign_keys = ON")
|
|
19
|
+
runMigrations(db)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("createIncident", () => {
|
|
23
|
+
test("happy path — creates incident with status=open", () => {
|
|
24
|
+
const inc = createIncident(db, { title: "DB down", severity: "sev1", summary: "Production DB unreachable" })
|
|
25
|
+
expect(inc.id).toBeTruthy()
|
|
26
|
+
expect(inc.title).toBe("DB down")
|
|
27
|
+
expect(inc.severity).toBe("sev1")
|
|
28
|
+
expect(inc.status).toBe("open")
|
|
29
|
+
expect(inc.summary).toBe("Production DB unreachable")
|
|
30
|
+
expect(inc.triggeredByDeploymentId).toBeNull()
|
|
31
|
+
expect(inc.createdAt).toBeGreaterThan(0)
|
|
32
|
+
expect(inc.updatedAt).toBeGreaterThan(0)
|
|
33
|
+
expect(inc.resolvedAt).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("creates incident without optional fields", () => {
|
|
37
|
+
const inc = createIncident(db, { title: "Minor issue", severity: "sev4" })
|
|
38
|
+
expect(inc.summary).toBeNull()
|
|
39
|
+
expect(inc.triggeredByDeploymentId).toBeNull()
|
|
40
|
+
expect(inc.metadata).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("creates incident with metadata", () => {
|
|
44
|
+
const inc = createIncident(db, { title: "Test", severity: "sev2", metadata: { region: "us-east-1" } })
|
|
45
|
+
expect(inc.metadata).toEqual({ region: "us-east-1" })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("throws on invalid severity", () => {
|
|
49
|
+
expect(() => createIncident(db, { title: "Bad", severity: "sev5" as any })).toThrow("invalid incident severity")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("throws on empty title (whitespace only)", () => {
|
|
53
|
+
expect(() => createIncident(db, { title: " ", severity: "sev1" })).toThrow("title cannot be empty")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("trims title", () => {
|
|
57
|
+
const inc = createIncident(db, { title: " spaced ", severity: "sev1" })
|
|
58
|
+
expect(inc.title).toBe("spaced")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("truncates summary >16KB", () => {
|
|
62
|
+
const bigSummary = "x".repeat(20_000)
|
|
63
|
+
const inc = createIncident(db, { title: "Big", severity: "sev1", summary: bigSummary })
|
|
64
|
+
expect(inc.summary!.length).toBeLessThan(20_000)
|
|
65
|
+
expect(inc.summary!).toContain("…[truncated]")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("FK validation — triggeredByDeploymentId non-existent throws", () => {
|
|
69
|
+
expect(() => createIncident(db, { title: "FK test", severity: "sev1", triggeredByDeploymentId: "nonexistent" })).toThrow("deployment 'nonexistent' not found")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("FK validation — valid triggeredByDeploymentId succeeds", () => {
|
|
73
|
+
createTestDeployment(db)
|
|
74
|
+
const inc = createIncident(db, { title: "Dep linked", severity: "sev2", triggeredByDeploymentId: "d1" })
|
|
75
|
+
expect(inc.triggeredByDeploymentId).toBe("d1")
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("getIncident", () => {
|
|
80
|
+
test("returns existing incident", () => {
|
|
81
|
+
const created = createIncident(db, { title: "Find me", severity: "sev3" })
|
|
82
|
+
const found = getIncident(db, created.id)
|
|
83
|
+
expect(found).not.toBeNull()
|
|
84
|
+
expect(found!.title).toBe("Find me")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("returns null for non-existent id", () => {
|
|
88
|
+
expect(getIncident(db, "nonexistent")).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("listIncidents", () => {
|
|
93
|
+
test("returns all incidents", () => {
|
|
94
|
+
createIncident(db, { title: "A", severity: "sev1" })
|
|
95
|
+
createIncident(db, { title: "B", severity: "sev2" })
|
|
96
|
+
const all = listIncidents(db)
|
|
97
|
+
expect(all.length).toBe(2)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("filters by severity", () => {
|
|
101
|
+
createIncident(db, { title: "Sev1", severity: "sev1" })
|
|
102
|
+
createIncident(db, { title: "Sev2", severity: "sev2" })
|
|
103
|
+
createIncident(db, { title: "Sev1b", severity: "sev1" })
|
|
104
|
+
const sev1 = listIncidents(db, { severity: "sev1" })
|
|
105
|
+
expect(sev1.length).toBe(2)
|
|
106
|
+
expect(sev1.every(i => i.severity === "sev1")).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("filters by status", () => {
|
|
110
|
+
const a = createIncident(db, { title: "A", severity: "sev1" })
|
|
111
|
+
createIncident(db, { title: "B", severity: "sev1" })
|
|
112
|
+
updateIncidentStatus(db, a.id, "triaging")
|
|
113
|
+
const open = listIncidents(db, { status: "open" })
|
|
114
|
+
const triaging = listIncidents(db, { status: "triaging" })
|
|
115
|
+
expect(open.length).toBe(1)
|
|
116
|
+
expect(triaging.length).toBe(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("respects limit", () => {
|
|
120
|
+
for (let i = 0; i < 5; i++) createIncident(db, { title: `I${i}`, severity: "sev1" })
|
|
121
|
+
const limited = listIncidents(db, { limit: 2 })
|
|
122
|
+
expect(limited.length).toBe(2)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("updateIncidentStatus", () => {
|
|
127
|
+
test("valid transition chain: open→triaging→mitigated→resolved→postmortem", () => {
|
|
128
|
+
const inc = createIncident(db, { title: "Chain", severity: "sev1" })
|
|
129
|
+
expect(inc.status).toBe("open")
|
|
130
|
+
|
|
131
|
+
const t1 = updateIncidentStatus(db, inc.id, "triaging")
|
|
132
|
+
expect(t1.status).toBe("triaging")
|
|
133
|
+
|
|
134
|
+
const t2 = updateIncidentStatus(db, inc.id, "mitigated")
|
|
135
|
+
expect(t2.status).toBe("mitigated")
|
|
136
|
+
|
|
137
|
+
const t3 = updateIncidentStatus(db, inc.id, "resolved")
|
|
138
|
+
expect(t3.status).toBe("resolved")
|
|
139
|
+
expect(t3.resolvedAt).toBeGreaterThan(0)
|
|
140
|
+
|
|
141
|
+
const t4 = updateIncidentStatus(db, inc.id, "postmortem")
|
|
142
|
+
expect(t4.status).toBe("postmortem")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("open→mitigated (skip triaging) is valid", () => {
|
|
146
|
+
const inc = createIncident(db, { title: "Skip", severity: "sev1" })
|
|
147
|
+
const updated = updateIncidentStatus(db, inc.id, "mitigated")
|
|
148
|
+
expect(updated.status).toBe("mitigated")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("open→resolved (skip both) is valid", () => {
|
|
152
|
+
const inc = createIncident(db, { title: "Fast", severity: "sev1" })
|
|
153
|
+
const updated = updateIncidentStatus(db, inc.id, "resolved")
|
|
154
|
+
expect(updated.status).toBe("resolved")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("throws on invalid transition: open→postmortem", () => {
|
|
158
|
+
const inc = createIncident(db, { title: "Bad", severity: "sev1" })
|
|
159
|
+
expect(() => updateIncidentStatus(db, inc.id, "postmortem")).toThrow("invalid incident transition")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("throws on backward transition: resolved→mitigated", () => {
|
|
163
|
+
const inc = createIncident(db, { title: "Back", severity: "sev1" })
|
|
164
|
+
updateIncidentStatus(db, inc.id, "resolved")
|
|
165
|
+
expect(() => updateIncidentStatus(db, inc.id, "mitigated")).toThrow("invalid incident transition")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("sets resolved_at when transitioning to resolved", () => {
|
|
169
|
+
const inc = createIncident(db, { title: "Resolve", severity: "sev1" })
|
|
170
|
+
expect(inc.resolvedAt).toBeNull()
|
|
171
|
+
const updated = updateIncidentStatus(db, inc.id, "resolved")
|
|
172
|
+
expect(updated.resolvedAt).toBeGreaterThan(0)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("preserves resolved_at when going to postmortem", () => {
|
|
176
|
+
const inc = createIncident(db, { title: "PM", severity: "sev1" })
|
|
177
|
+
const resolved = updateIncidentStatus(db, inc.id, "resolved")
|
|
178
|
+
const resolvedAt = resolved.resolvedAt!
|
|
179
|
+
const pm = updateIncidentStatus(db, inc.id, "postmortem")
|
|
180
|
+
expect(pm.resolvedAt).toBe(resolvedAt)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("no-op when same status", () => {
|
|
184
|
+
const inc = createIncident(db, { title: "Same", severity: "sev1" })
|
|
185
|
+
const same = updateIncidentStatus(db, inc.id, "open")
|
|
186
|
+
expect(same.id).toBe(inc.id)
|
|
187
|
+
expect(same.status).toBe("open")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("throws on non-existent incident", () => {
|
|
191
|
+
expect(() => updateIncidentStatus(db, "nonexistent", "triaging")).toThrow("incident 'nonexistent' not found")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("postmortem is terminal — no further transitions", () => {
|
|
195
|
+
const inc = createIncident(db, { title: "Terminal", severity: "sev1" })
|
|
196
|
+
updateIncidentStatus(db, inc.id, "resolved")
|
|
197
|
+
updateIncidentStatus(db, inc.id, "postmortem")
|
|
198
|
+
expect(() => updateIncidentStatus(db, inc.id, "resolved")).toThrow("invalid incident transition")
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Incident CRUD with transition validation.
|
|
3
|
+
*
|
|
4
|
+
* All functions take a Database instance and return camelCase TS types.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "bun:sqlite"
|
|
8
|
+
import { incidentFromRow, type Incident, type IncidentSeverity, type IncidentStatus, type InsertIncident } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
const MAX_TEXT_BYTES = 16 * 1024
|
|
11
|
+
|
|
12
|
+
function truncateText(s: string): string {
|
|
13
|
+
if (s.length <= MAX_TEXT_BYTES) return s
|
|
14
|
+
return `${s.slice(0, MAX_TEXT_BYTES)}…[truncated]`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const VALID_SEVERITIES: IncidentSeverity[] = ["sev1", "sev2", "sev3", "sev4"]
|
|
18
|
+
const VALID_STATUSES: IncidentStatus[] = ["open", "triaging", "mitigated", "resolved", "postmortem"]
|
|
19
|
+
|
|
20
|
+
// Valid transitions: forward only, no backward
|
|
21
|
+
const VALID_TRANSITIONS: Record<IncidentStatus, IncidentStatus[]> = {
|
|
22
|
+
open: ["triaging", "mitigated", "resolved"],
|
|
23
|
+
triaging: ["mitigated", "resolved"],
|
|
24
|
+
mitigated: ["resolved"],
|
|
25
|
+
resolved: ["postmortem"],
|
|
26
|
+
postmortem: [], // terminal
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createIncident(
|
|
30
|
+
db: Database,
|
|
31
|
+
input: InsertIncident,
|
|
32
|
+
): Incident {
|
|
33
|
+
if (!VALID_SEVERITIES.includes(input.severity)) {
|
|
34
|
+
throw new Error(`ndomo: invalid incident severity '${input.severity}' (must be sev1-4)`)
|
|
35
|
+
}
|
|
36
|
+
const title = input.title.trim()
|
|
37
|
+
if (title.length === 0) throw new Error("ndomo: incident title cannot be empty")
|
|
38
|
+
const summary = input.summary ? truncateText(input.summary.trim()) : null
|
|
39
|
+
if (input.triggeredByDeploymentId) {
|
|
40
|
+
const dep = db.query("SELECT id FROM deployments WHERE id = ?").get(input.triggeredByDeploymentId)
|
|
41
|
+
if (!dep) throw new Error(`ndomo: deployment '${input.triggeredByDeploymentId}' not found (FK violation)`)
|
|
42
|
+
}
|
|
43
|
+
const id = crypto.randomUUID()
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
const metadata = input.metadata ? JSON.stringify(input.metadata) : null
|
|
46
|
+
db.query(
|
|
47
|
+
`INSERT INTO incidents (id, title, severity, status, summary, triggered_by_deployment_id, created_at, updated_at, resolved_at, metadata)
|
|
48
|
+
VALUES (?, ?, ?, 'open', ?, ?, ?, ?, NULL, ?)`,
|
|
49
|
+
).run(id, title, input.severity, summary, input.triggeredByDeploymentId ?? null, now, now, metadata)
|
|
50
|
+
return getIncident(db, id)!
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getIncident(db: Database, id: string): Incident | null {
|
|
54
|
+
const row = db.query("SELECT * FROM incidents WHERE id = ?").get(id)
|
|
55
|
+
return row ? incidentFromRow(row) : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listIncidents(
|
|
59
|
+
db: Database,
|
|
60
|
+
opts?: { status?: IncidentStatus; severity?: IncidentSeverity; limit?: number },
|
|
61
|
+
): Incident[] {
|
|
62
|
+
const limit = opts?.limit ?? 100
|
|
63
|
+
const clauses: string[] = []
|
|
64
|
+
const params: (string | number)[] = []
|
|
65
|
+
if (opts?.status) { clauses.push("status = ?"); params.push(opts.status) }
|
|
66
|
+
if (opts?.severity) { clauses.push("severity = ?"); params.push(opts.severity) }
|
|
67
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""
|
|
68
|
+
params.push(limit)
|
|
69
|
+
const rows = db.query(`SELECT * FROM incidents ${where} ORDER BY created_at DESC LIMIT ?`).all(...params)
|
|
70
|
+
return rows.map(incidentFromRow)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function updateIncidentStatus(
|
|
74
|
+
db: Database,
|
|
75
|
+
id: string,
|
|
76
|
+
newStatus: IncidentStatus,
|
|
77
|
+
): Incident {
|
|
78
|
+
const current = getIncident(db, id)
|
|
79
|
+
if (!current) throw new Error(`ndomo: incident '${id}' not found`)
|
|
80
|
+
if (!VALID_STATUSES.includes(newStatus)) {
|
|
81
|
+
throw new Error(`ndomo: invalid incident status '${newStatus}'`)
|
|
82
|
+
}
|
|
83
|
+
const currentStatus = current.status
|
|
84
|
+
if (currentStatus === newStatus) return current // no-op
|
|
85
|
+
const allowed = VALID_TRANSITIONS[currentStatus]
|
|
86
|
+
if (!allowed.includes(newStatus)) {
|
|
87
|
+
throw new Error(`ndomo: invalid incident transition '${currentStatus}' → '${newStatus}' (allowed: ${allowed.join(", ") || "none (terminal)"})`)
|
|
88
|
+
}
|
|
89
|
+
const now = Date.now()
|
|
90
|
+
const resolvedAt = newStatus === "resolved" || newStatus === "postmortem" ? now : current.resolvedAt
|
|
91
|
+
db.query("UPDATE incidents SET status = ?, updated_at = ?, resolved_at = ? WHERE id = ?").run(newStatus, now, resolvedAt, id)
|
|
92
|
+
return getIncident(db, id)!
|
|
93
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public DB API for ndomo.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported so custom tools can `import { createPlan, openDb, ... } from "ndomo/db"`.
|
|
5
|
+
* Named re-exports only (no `export *`) for explicitness and tree-shaking clarity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
9
|
+
export { closeDb, openDb } from "./client.ts";
|
|
10
|
+
// ─── Migrations ──────────────────────────────────────────────────────────────
|
|
11
|
+
export { runMigrations } from "./migrations.ts";
|
|
12
|
+
export type { ArchiveResult } from "./plan-archive.ts";
|
|
13
|
+
// ─── Plan Archive ────────────────────────────────────────────────────────────
|
|
14
|
+
export { archivePlan, resolveArchiveDir } from "./plan-archive.ts";
|
|
15
|
+
export type { PlanProgress } from "./plans.ts";
|
|
16
|
+
// ─── Plans ───────────────────────────────────────────────────────────────────
|
|
17
|
+
export {
|
|
18
|
+
addPlanTag,
|
|
19
|
+
approvePlan,
|
|
20
|
+
createPlan,
|
|
21
|
+
findPlansByCategory,
|
|
22
|
+
findPlansByTag,
|
|
23
|
+
getPlan,
|
|
24
|
+
getPlanBySlug,
|
|
25
|
+
getPlanProgress,
|
|
26
|
+
getPlanTags,
|
|
27
|
+
listPlans,
|
|
28
|
+
removePlanTag,
|
|
29
|
+
searchPlans,
|
|
30
|
+
updatePlanStatus,
|
|
31
|
+
} from "./plans.ts";
|
|
32
|
+
export type { ProjectDirContext } from "./resolve-project-dir.ts";
|
|
33
|
+
// ─── Project dir resolution ──────────────────────────────────────────────────
|
|
34
|
+
export { resolveProjectDir } from "./resolve-project-dir.ts";
|
|
35
|
+
// ─── Sessions ────────────────────────────────────────────────────────────────
|
|
36
|
+
export {
|
|
37
|
+
appendAgentHistory,
|
|
38
|
+
checkpointSession,
|
|
39
|
+
endSession,
|
|
40
|
+
getSession,
|
|
41
|
+
listSessions,
|
|
42
|
+
startSession,
|
|
43
|
+
} from "./sessions.ts";
|
|
44
|
+
export type { TaskTruncationInfo, TaskUpdateResult } from "./tasks.ts";
|
|
45
|
+
// ─── Tasks ───────────────────────────────────────────────────────────────────
|
|
46
|
+
export {
|
|
47
|
+
addTaskTag,
|
|
48
|
+
createTasksBatch,
|
|
49
|
+
getTask,
|
|
50
|
+
getTaskTags,
|
|
51
|
+
listTasksByPlan,
|
|
52
|
+
nextTaskForAgent,
|
|
53
|
+
removeTaskTag,
|
|
54
|
+
searchTasks,
|
|
55
|
+
splitFilesByStack,
|
|
56
|
+
updateTaskStatus,
|
|
57
|
+
} from "./tasks.ts";
|
|
58
|
+
|
|
59
|
+
// ─── Analyses (v14) ────────────────────────────────────────────────────────
|
|
60
|
+
export {
|
|
61
|
+
archiveAnalysis,
|
|
62
|
+
createAnalysis,
|
|
63
|
+
getAnalysis,
|
|
64
|
+
getAnalysisBySlug,
|
|
65
|
+
linkAnalysisToPlan,
|
|
66
|
+
listAnalyses,
|
|
67
|
+
searchAnalyses,
|
|
68
|
+
unlinkAnalysisFromPlan,
|
|
69
|
+
updateAnalysis,
|
|
70
|
+
} from "./analyses.ts";
|
|
71
|
+
|
|
72
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
73
|
+
export type {
|
|
74
|
+
Analysis,
|
|
75
|
+
InsertAnalysis,
|
|
76
|
+
Plan,
|
|
77
|
+
PlanCategory,
|
|
78
|
+
PlanMetadata,
|
|
79
|
+
PlanStatus,
|
|
80
|
+
PlanTask,
|
|
81
|
+
Session,
|
|
82
|
+
SessionMetadata,
|
|
83
|
+
SessionOutcome,
|
|
84
|
+
TaskMetadata,
|
|
85
|
+
TaskStatus,
|
|
86
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test"
|
|
3
|
+
import { runMigrations } from "./migrations.ts"
|
|
4
|
+
import { MIGRATIONS } from "./schema.ts"
|
|
5
|
+
|
|
6
|
+
describe("migration v13 — ops tables (T2)", () => {
|
|
7
|
+
let db: Database
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
db = new Database(":memory:")
|
|
10
|
+
db.exec("PRAGMA foreign_keys = ON")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("applies v13 and sets schema_version=15 (latest)", () => {
|
|
14
|
+
runMigrations(db)
|
|
15
|
+
const row = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number }
|
|
16
|
+
expect(row.v).toBe(15)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("all 5 ops tables exist", () => {
|
|
20
|
+
runMigrations(db)
|
|
21
|
+
const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('environments','releases','deployments','incidents','rollback_executions') ORDER BY name").all() as Array<{ name: string }>
|
|
22
|
+
expect(tables.map(t => t.name)).toEqual(["deployments", "environments", "incidents", "releases", "rollback_executions"])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("idempotent — running migrations twice is a no-op", () => {
|
|
26
|
+
runMigrations(db)
|
|
27
|
+
const v1 = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number }
|
|
28
|
+
runMigrations(db) // second run
|
|
29
|
+
const v2 = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number }
|
|
30
|
+
expect(v2).toEqual(v1)
|
|
31
|
+
// Verify no duplicate schema_version rows
|
|
32
|
+
const count = db.query("SELECT COUNT(*) as c FROM schema_version WHERE version=13").get() as { c: number }
|
|
33
|
+
expect(count.c).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("environments table has expected columns", () => {
|
|
37
|
+
runMigrations(db)
|
|
38
|
+
const cols = db.query("PRAGMA table_info(environments)").all() as Array<{ name: string }>
|
|
39
|
+
const names = cols.map(c => c.name)
|
|
40
|
+
expect(names).toContain("id")
|
|
41
|
+
expect(names).toContain("name")
|
|
42
|
+
expect(names).toContain("slug")
|
|
43
|
+
expect(names).toContain("description")
|
|
44
|
+
expect(names).toContain("metadata")
|
|
45
|
+
expect(names).toContain("created_at")
|
|
46
|
+
expect(names).toContain("updated_at")
|
|
47
|
+
expect(names).toContain("archived_at")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("releases table has expected columns", () => {
|
|
51
|
+
runMigrations(db)
|
|
52
|
+
const cols = db.query("PRAGMA table_info(releases)").all() as Array<{ name: string }>
|
|
53
|
+
const names = cols.map(c => c.name)
|
|
54
|
+
expect(names).toContain("id")
|
|
55
|
+
expect(names).toContain("version")
|
|
56
|
+
expect(names).toContain("title")
|
|
57
|
+
expect(names).toContain("notes")
|
|
58
|
+
expect(names).toContain("created_at")
|
|
59
|
+
expect(names).toContain("archived_at")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("deployments table has FK to releases + environments", () => {
|
|
63
|
+
runMigrations(db)
|
|
64
|
+
const fks = db.query("PRAGMA foreign_key_list(deployments)").all() as Array<{ table: string }>
|
|
65
|
+
const tables = fks.map(f => f.table)
|
|
66
|
+
expect(tables).toContain("releases")
|
|
67
|
+
expect(tables).toContain("environments")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("incidents table has FK to deployments (triggered_by_deployment_id)", () => {
|
|
71
|
+
runMigrations(db)
|
|
72
|
+
const fks = db.query("PRAGMA foreign_key_list(incidents)").all() as Array<{ table: string; from: string }>
|
|
73
|
+
const depFk = fks.find(f => f.table === "deployments")
|
|
74
|
+
expect(depFk).toBeDefined()
|
|
75
|
+
expect(depFk?.from).toBe("triggered_by_deployment_id")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("rollback_executions has FKs to deployments + incidents", () => {
|
|
79
|
+
runMigrations(db)
|
|
80
|
+
const fks = db.query("PRAGMA foreign_key_list(rollback_executions)").all() as Array<{ table: string; from: string }>
|
|
81
|
+
const tables = fks.map(f => f.table)
|
|
82
|
+
expect(tables).toContain("deployments")
|
|
83
|
+
expect(tables).toContain("incidents")
|
|
84
|
+
// Should have 2 FKs to deployments (deployment_id + new_deployment_id)
|
|
85
|
+
const depFks = fks.filter(f => f.table === "deployments")
|
|
86
|
+
expect(depFks.length).toBe(2)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("indices exist on key columns", () => {
|
|
90
|
+
runMigrations(db)
|
|
91
|
+
const indices = db.query("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'").all() as Array<{ name: string }>
|
|
92
|
+
const names = indices.map(i => i.name)
|
|
93
|
+
expect(names).toContain("idx_environments_slug")
|
|
94
|
+
expect(names).toContain("idx_releases_version")
|
|
95
|
+
expect(names).toContain("idx_deployments_status")
|
|
96
|
+
expect(names).toContain("idx_incidents_severity")
|
|
97
|
+
expect(names).toContain("idx_rollbacks_deployment")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("CHECK constraints enforce severity enum", () => {
|
|
101
|
+
runMigrations(db)
|
|
102
|
+
// Insert valid severity
|
|
103
|
+
db.query("INSERT INTO incidents (id, title, severity, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("i1", "test", "sev1", "open", Date.now(), Date.now())
|
|
104
|
+
// Invalid severity should fail
|
|
105
|
+
expect(() => {
|
|
106
|
+
db.query("INSERT INTO incidents (id, title, severity, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("i2", "test", "sev5", "open", Date.now(), Date.now())
|
|
107
|
+
}).toThrow()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("CHECK constraints enforce incident status enum", () => {
|
|
111
|
+
runMigrations(db)
|
|
112
|
+
expect(() => {
|
|
113
|
+
db.query("INSERT INTO incidents (id, title, severity, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("i3", "test", "sev1", "invalid_status", Date.now(), Date.now())
|
|
114
|
+
}).toThrow()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("CHECK constraints enforce rollback status enum", () => {
|
|
118
|
+
runMigrations(db)
|
|
119
|
+
// Need a deployment first for FK
|
|
120
|
+
db.query("INSERT INTO environments (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run("e1", "prod", "prod", Date.now(), Date.now())
|
|
121
|
+
db.query("INSERT INTO releases (id, version, title, created_at) VALUES (?, ?, ?, ?)").run("r1", "1.0.0", "rel", Date.now())
|
|
122
|
+
db.query("INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)").run("d1", "r1", "e1", "planned", Date.now())
|
|
123
|
+
expect(() => {
|
|
124
|
+
db.query("INSERT INTO rollback_executions (id, deployment_id, status, plan, created_at) VALUES (?, ?, ?, ?, ?)").run("rb1", "d1", "invalid_status", "plan", Date.now())
|
|
125
|
+
}).toThrow()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("FK constraint — cannot insert deployment with non-existent release", () => {
|
|
129
|
+
runMigrations(db)
|
|
130
|
+
db.query("INSERT INTO environments (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run("e1", "prod", "prod", Date.now(), Date.now())
|
|
131
|
+
expect(() => {
|
|
132
|
+
db.query("INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)").run("d1", "nonexistent", "e1", "planned", Date.now())
|
|
133
|
+
}).toThrow()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("v15 is the last entry in MIGRATIONS array", () => {
|
|
137
|
+
const last = MIGRATIONS[MIGRATIONS.length - 1]
|
|
138
|
+
expect(last).toBeDefined()
|
|
139
|
+
expect(last!.version).toBe(15)
|
|
140
|
+
})
|
|
141
|
+
})
|