@nanobpm/nano-workforce 0.105.0 → 0.106.1
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/.github/workflows/ci.yml +5 -0
- package/AGENTS.md +31 -1
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/app/agentCompletion.test.ts +28 -0
- package/app/agentCompletion.ts +14 -2
- package/app/conformance.test.ts +94 -1
- package/app/conformance.ts +89 -0
- package/app/instance-tracking.test.ts +24 -0
- package/app/migration-upgrade-smoke.test.ts +55 -0
- package/app/migrationHeal.test.ts +75 -0
- package/app/migrationHeal.ts +74 -0
- package/app/pollUserTasks.test.ts +37 -0
- package/app/service.ts +40 -0
- package/app/userTasks.test.ts +20 -0
- package/app/userTasks.ts +2 -0
- package/db/migrations/054_conformance_review_tracking.sql +27 -0
- package/nano.app.json +18 -0
- package/package.json +4 -2
- package/pages/tasks.page.json +84 -0
- package/resources/forms/conformance-escalation.form +17 -0
- package/resources/processes/retro.bpmn +74 -13
- package/scripts/check-migrations.test.ts +58 -0
- package/scripts/check-migrations.ts +127 -5
- package/scripts/heal-migration-ledger.ts +49 -0
- package/test/migrations.ts +121 -0
- package/workers/conformance-ack/worker.test.ts +50 -0
- package/workers/conformance-ack/worker.ts +31 -0
- package/workers/conformance-record/worker.test.ts +47 -5
- package/workers/conformance-record/worker.ts +33 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -16,6 +16,11 @@ jobs:
|
|
|
16
16
|
steps:
|
|
17
17
|
- name: Checkout
|
|
18
18
|
uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
# Full history + tags: the migration immutability gate (check:migrations) diffs against the
|
|
21
|
+
# merge-base with origin/main, and the upgrade smoke test materialises the previous release
|
|
22
|
+
# tag's migration set — both need history a shallow clone doesn't have (issue #357).
|
|
23
|
+
fetch-depth: 0
|
|
19
24
|
|
|
20
25
|
- name: Setup Node.js
|
|
21
26
|
uses: actions/setup-node@v4
|
package/AGENTS.md
CHANGED
|
@@ -273,6 +273,36 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
|
|
|
273
273
|
prefix while `main` keeps advancing, so the branch-local "next" number collides
|
|
274
274
|
on merge. Two files must never share a prefix; `npm run check:migrations`
|
|
275
275
|
(a CI gate) enforces this and fails the build on any new duplicate.
|
|
276
|
+
- **A merged migration is IMMUTABLE — never rename, delete, or edit it.** The
|
|
277
|
+
runtime keys the `_urban_migrations` ledger by *filename*, so a renamed file is
|
|
278
|
+
a *new* migration to the runner: it re-runs its DDL against an already-migrated
|
|
279
|
+
DB and aborts boot (`duplicate column …`); a deleted file desyncs the ledger
|
|
280
|
+
from the schema; an edited file silently no-ops on every existing install (the
|
|
281
|
+
name is already recorded) while diverging fresh ones. To change a merged
|
|
282
|
+
migration's effect, add a NEW migration. `npm run check:migrations` also gates
|
|
283
|
+
this — it diffs `db/migrations/` against the merge-base with `origin/main` and
|
|
284
|
+
fails on any rename/delete/edit — and the upgrade smoke test
|
|
285
|
+
(`app/migration-upgrade-smoke.test.ts`) materialises the previous release's
|
|
286
|
+
migration set and upgrades it to the current set, catching non-idempotent DDL a
|
|
287
|
+
fresh-DB CI never exercises (issue #357). Both need history: CI checks out with
|
|
288
|
+
`fetch-depth: 0`.
|
|
289
|
+
|
|
290
|
+
### Healing an install wedged by a renamed migration
|
|
291
|
+
|
|
292
|
+
If a live node fails to boot with `migration "NNN_…sql" failed and was rolled
|
|
293
|
+
back … duplicate column` because a migration was renamed *before* the immutability
|
|
294
|
+
gate existed (e.g. `043_user_tasks_subject_title.sql` → `046_…`, issue #357),
|
|
295
|
+
reconcile its ledger — this makes **no schema change**, only aliases the old
|
|
296
|
+
ledger row to the new filename, and is safe to re-run:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
npm run heal:migrations -- /path/to/the/app.sqlite # then restart the node
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The known renames live in `RENAMED_MIGRATIONS` (`app/migrationHeal.ts`), the
|
|
303
|
+
single source of truth the heal script and its tests share. This list only heals
|
|
304
|
+
the pre-gate past — the immutability gate above prevents any new entry from ever
|
|
305
|
+
being needed.
|
|
276
306
|
|
|
277
307
|
## Runtime & CI gates
|
|
278
308
|
|
|
@@ -286,7 +316,7 @@ npm run typecheck # tsc --noEmit (Node)
|
|
|
286
316
|
npm run check # urban check (manifest validation)
|
|
287
317
|
npm run layout:check # BPMN diagram freshness (no drift)
|
|
288
318
|
npm run check:prompts # agent-prompt linkedResource resolution
|
|
289
|
-
npm run check:migrations # migration prefixes (no
|
|
319
|
+
npm run check:migrations # migration prefixes + immutability (no rename/delete/edit of a merged migration)
|
|
290
320
|
npm run check:contracts # contract registry (no synonyms / undeclared env keys)
|
|
291
321
|
npm test # unit tests (node --test)
|
|
292
322
|
```
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.106.1](https://github.com/nanobpm/nano-workforce/compare/v0.106.0...v0.106.1) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **migrations:** gate migration immutability + add upgrade smoke test ([#357](https://github.com/nanobpm/nano-workforce/issues/357)) ([#359](https://github.com/nanobpm/nano-workforce/issues/359)) ([7f7c6a6](https://github.com/nanobpm/nano-workforce/commit/7f7c6a6c59dac33602441c729dca897b3b23fabc)), closes [#311](https://github.com/nanobpm/nano-workforce/issues/311) [#316](https://github.com/nanobpm/nano-workforce/issues/316) [#351](https://github.com/nanobpm/nano-workforce/issues/351) [#355](https://github.com/nanobpm/nano-workforce/issues/355)
|
|
7
|
+
|
|
8
|
+
# [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* escalate unmet/undisclosed conformance deviations to the Tasks inbox ([#356](https://github.com/nanobpm/nano-workforce/issues/356)) ([5ad24bc](https://github.com/nanobpm/nano-workforce/commit/5ad24bcadf833934804973a25fd96f4eb4d28a84)), closes [#354](https://github.com/nanobpm/nano-workforce/issues/354) [#354](https://github.com/nanobpm/nano-workforce/issues/354)
|
|
14
|
+
|
|
1
15
|
# [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ capability):
|
|
|
153
153
|
| `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
|
|
154
154
|
| `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
|
|
155
155
|
| `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
|
|
156
|
-
| `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue |
|
|
156
|
+
| `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue, and escalate an unmet/undisclosed deviation to the Tasks inbox as a non-blocking ack |
|
|
157
157
|
|
|
158
158
|
- `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
|
|
159
159
|
prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
|
|
@@ -229,6 +229,34 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
|
|
|
229
229
|
assertEquals(completed[0].variables, { note: "reassigned to a human" });
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
|
|
233
|
+
// The retro conformance ack mirrors feature-blocked: a HUMAN operator retires it via
|
|
234
|
+
// `completeEscalationAsHuman`, but it stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) —
|
|
235
|
+
// an agent must never acknowledge a conformance review on a human's behalf.
|
|
236
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
237
|
+
const data = memData(stores);
|
|
238
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-c", elementId: "conformance-escalation" }]);
|
|
239
|
+
|
|
240
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
241
|
+
userTaskKey: "ut-c",
|
|
242
|
+
agentId: "bot",
|
|
243
|
+
variables: { note: "n" },
|
|
244
|
+
});
|
|
245
|
+
assertEquals(asAgent.ok, false, "the agent completer refuses conformance-escalation");
|
|
246
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
247
|
+
assertEquals(completed.length, 0);
|
|
248
|
+
|
|
249
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
250
|
+
userTaskKey: "ut-c",
|
|
251
|
+
operatorId: "alice",
|
|
252
|
+
variables: { note: "filed follow-up" },
|
|
253
|
+
});
|
|
254
|
+
assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
|
|
255
|
+
assertEquals(asHuman.elementId, "conformance-escalation");
|
|
256
|
+
assertEquals(completed.length, 1);
|
|
257
|
+
assertEquals(completed[0].variables, { note: "filed follow-up" });
|
|
258
|
+
});
|
|
259
|
+
|
|
232
260
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
233
261
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
234
262
|
const data = memData(stores);
|
package/app/agentCompletion.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
25
|
+
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
25
26
|
|
|
26
27
|
const now = () => new Date().toISOString();
|
|
27
28
|
|
|
@@ -79,13 +80,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
79
80
|
* `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
|
|
80
81
|
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
81
82
|
|
|
83
|
+
/** The `conformance-escalation` operator user-task element id (retro.bpmn) — the native ack a retro
|
|
84
|
+
* run parks on when the spec-conformance audit finds the epic did NOT cleanly meet its spec (a
|
|
85
|
+
* reduced / not-verified slice, or an unraised deviation). Like `feature-blocked` it is a human-only
|
|
86
|
+
* acknowledgement (never agent-answerable), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only
|
|
87
|
+
* the HUMAN completer accepts it (issue #216). Re-exported from the canonical
|
|
88
|
+
* `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
|
|
89
|
+
export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
|
|
90
|
+
|
|
82
91
|
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
83
92
|
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
84
|
-
*
|
|
85
|
-
* so widening the human surface never lets an agent retire
|
|
93
|
+
* and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
|
|
94
|
+
* `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
|
|
95
|
+
* a blocked run or a conformance review. */
|
|
86
96
|
export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
87
97
|
...ESCALATION_TASK_ELEMENTS,
|
|
88
98
|
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
99
|
+
CONFORMANCE_ESCALATION_TASK_ELEMENT,
|
|
89
100
|
]);
|
|
90
101
|
|
|
91
102
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
@@ -98,6 +109,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
98
109
|
"wait-answer": "pr-escalation",
|
|
99
110
|
"wait-merge-answer": "pr-escalation",
|
|
100
111
|
"feature-blocked": "feature-blocked",
|
|
112
|
+
[CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
|
|
101
113
|
};
|
|
102
114
|
|
|
103
115
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
package/app/conformance.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
|
+
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
4
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
6
|
import { appendEntry } from "./blackboard.ts";
|
|
7
7
|
import {
|
|
8
|
+
acknowledgeConformance,
|
|
9
|
+
activeConformanceReviews,
|
|
8
10
|
gatherConformance,
|
|
9
11
|
hasDeliveredImplementation,
|
|
10
12
|
hasDeliveredImplementationForPlan,
|
|
@@ -218,3 +220,94 @@ test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error in
|
|
|
218
220
|
assert(threw, "the FK error must propagate");
|
|
219
221
|
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
220
222
|
});
|
|
223
|
+
|
|
224
|
+
test("recordConformance: persists the retro instance key and the escalation review_status (issue #216)", async () => {
|
|
225
|
+
const { data, stores } = memData();
|
|
226
|
+
await recordConformance(data, PLAN, {
|
|
227
|
+
status: "filed",
|
|
228
|
+
commentUrl: "https://x/7#c",
|
|
229
|
+
hasDeviations: true,
|
|
230
|
+
summary: "slice 2 reduced",
|
|
231
|
+
processKey: "retro-inst-7",
|
|
232
|
+
reviewStatus: "reviewing",
|
|
233
|
+
});
|
|
234
|
+
const row = stores["plan_conformance"][0];
|
|
235
|
+
assertEquals(row.process_key, "retro-inst-7");
|
|
236
|
+
assertEquals(row.review_status, "reviewing");
|
|
237
|
+
|
|
238
|
+
// Absent tracking fields default: no processKey, and a settled `reviewed`.
|
|
239
|
+
await recordConformance(data, "acme/widgets#8", { status: "skipped" });
|
|
240
|
+
const clean = stores["plan_conformance"].find((r) => r.plan_key === "acme/widgets#8");
|
|
241
|
+
assertEquals(clean.process_key, null);
|
|
242
|
+
assertEquals(clean.review_status, "reviewed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("recordConformance: rejects an untrackable `reviewing` row with a null processKey (invariant guard)", async () => {
|
|
246
|
+
const { data, stores } = memData();
|
|
247
|
+
// A `reviewing` row with no `process_key` is unreachable: `pollUserTasks` skips rows without a
|
|
248
|
+
// key and the `instanceTracking` binding keys off `process_key`, so it would strand the review
|
|
249
|
+
// forever. The API must refuse to persist that state, not just the worker call site.
|
|
250
|
+
await assertRejects(
|
|
251
|
+
() =>
|
|
252
|
+
recordConformance(data, PLAN, {
|
|
253
|
+
status: "filed",
|
|
254
|
+
hasDeviations: true,
|
|
255
|
+
reviewStatus: "reviewing",
|
|
256
|
+
}),
|
|
257
|
+
Error,
|
|
258
|
+
"untrackable",
|
|
259
|
+
);
|
|
260
|
+
assertEquals(stores["plan_conformance"] ?? [], []);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("activeConformanceReviews: returns only the rows still `reviewing`", async () => {
|
|
264
|
+
const { data, stores } = memData();
|
|
265
|
+
stores["plan_conformance"] = [
|
|
266
|
+
{ plan_key: "a/b#1", process_key: "p1", review_status: "reviewing", summary: "s1" },
|
|
267
|
+
{ plan_key: "a/b#2", process_key: "p2", review_status: "reviewed", summary: "s2" },
|
|
268
|
+
];
|
|
269
|
+
const active = await activeConformanceReviews(data);
|
|
270
|
+
assertEquals(active.map((r) => r.plan_key), ["a/b#1"]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("acknowledgeConformance: settles the row at reviewed and folds the note into the summary", async () => {
|
|
274
|
+
const { data, stores } = memData();
|
|
275
|
+
stores["plan_conformance"] = [
|
|
276
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
|
|
277
|
+
];
|
|
278
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
279
|
+
const row = stores["plan_conformance"][0];
|
|
280
|
+
assertEquals(row.review_status, "reviewed");
|
|
281
|
+
assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("acknowledgeConformance: fails loudly when the plan_conformance row is missing", async () => {
|
|
285
|
+
const { data } = memData();
|
|
286
|
+
await assertRejects(
|
|
287
|
+
() => acknowledgeConformance(data, PLAN, "filed follow-up #9"),
|
|
288
|
+
Error,
|
|
289
|
+
PLAN,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("acknowledgeConformance: is idempotent — a retry after settling does not re-append the note", async () => {
|
|
294
|
+
const { data, stores } = memData();
|
|
295
|
+
stores["plan_conformance"] = [
|
|
296
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
|
|
297
|
+
];
|
|
298
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
299
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
300
|
+
const row = stores["plan_conformance"][0];
|
|
301
|
+
assertEquals(row.review_status, "reviewed");
|
|
302
|
+
assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("acknowledgeConformance: a blank note settles the row without changing the summary", async () => {
|
|
306
|
+
const { data, stores } = memData();
|
|
307
|
+
stores["plan_conformance"] = [
|
|
308
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "auth cache unverified" },
|
|
309
|
+
];
|
|
310
|
+
await acknowledgeConformance(data, PLAN, " ");
|
|
311
|
+
assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
|
|
312
|
+
assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
|
|
313
|
+
});
|
package/app/conformance.ts
CHANGED
|
@@ -21,6 +21,14 @@ import { planTasks } from "./plan.ts";
|
|
|
21
21
|
|
|
22
22
|
const now = () => new Date().toISOString();
|
|
23
23
|
|
|
24
|
+
/** The BPMN `elementId` of the conformance escalation user task (retro.bpmn). The inbox reconciler
|
|
25
|
+
* (`pollUserTasks`) and the human completer (`HUMAN_COMPLETABLE_ELEMENTS`) key off this. */
|
|
26
|
+
export const CONFORMANCE_ESCALATION_ELEMENT = "conformance-escalation";
|
|
27
|
+
|
|
28
|
+
/** The `review_status` a `plan_conformance` row carries while its escalation ack task is OPEN — the
|
|
29
|
+
* only status `pollUserTasks` scans (migration 054). Every settled run is `reviewed`. */
|
|
30
|
+
export const CONFORMANCE_REVIEWING_STATUS = "reviewing";
|
|
31
|
+
|
|
24
32
|
/** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
|
|
25
33
|
* PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
|
|
26
34
|
* in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
|
|
@@ -50,6 +58,30 @@ async function isLanded(data: DataLayer, prKey: string | null | undefined): Prom
|
|
|
50
58
|
const conformanceTbl = (data: DataLayer) =>
|
|
51
59
|
data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
|
|
52
60
|
|
|
61
|
+
/** A `plan_conformance` row viewed as a retro-run tracking record, for the inbox reconciler. */
|
|
62
|
+
export interface ConformanceReviewRow extends Record<string, unknown> {
|
|
63
|
+
plan_key: string;
|
|
64
|
+
process_key: string | null;
|
|
65
|
+
review_status: string;
|
|
66
|
+
summary: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const conformanceReviewsTbl = (data: DataLayer) =>
|
|
70
|
+
data.table<ConformanceReviewRow>("plan_conformance", "plan_key");
|
|
71
|
+
|
|
72
|
+
/** The conformance runs whose escalation ack task is still open (`review_status = 'reviewing'`) —
|
|
73
|
+
* the set `pollUserTasks` scans for an open `conformance-escalation` user task. */
|
|
74
|
+
export async function activeConformanceReviews(data: DataLayer): Promise<ConformanceReviewRow[]> {
|
|
75
|
+
return await conformanceReviewsTbl(data).find({ review_status: CONFORMANCE_REVIEWING_STATUS });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The escalation question shown in the inbox row: the agent's conformance summary (which names the
|
|
79
|
+
* reduced / not-verified items and the unraised deviations). Best-effort — NULL when none recorded. */
|
|
80
|
+
export function conformanceEscalationQuestion(row: { summary?: unknown } | undefined): string | null {
|
|
81
|
+
const s = row?.summary;
|
|
82
|
+
return typeof s === "string" && s.trim() ? s.trim() : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
/** One item of the spec the agent must verify against the code: the slice's planner-supplied
|
|
54
86
|
* `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
|
|
55
87
|
export interface ConformanceSlice {
|
|
@@ -205,6 +237,12 @@ export interface ConformanceInput {
|
|
|
205
237
|
hasDeviations?: boolean;
|
|
206
238
|
summary?: string | null;
|
|
207
239
|
report?: string | null;
|
|
240
|
+
/** The retro process instance this conformance ran in — the tracking key `pollUserTasks` reads to
|
|
241
|
+
* find an open escalation user task (migration 054). */
|
|
242
|
+
processKey?: string | null;
|
|
243
|
+
/** Escalation lifecycle: `reviewing` while the ack task is open (poller scans these), else
|
|
244
|
+
* `reviewed`. Defaults to `reviewed` — only an escalation flips it to `reviewing`. */
|
|
245
|
+
reviewStatus?: "reviewing" | "reviewed";
|
|
208
246
|
}
|
|
209
247
|
|
|
210
248
|
/** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
|
|
@@ -218,6 +256,19 @@ export async function recordConformance(
|
|
|
218
256
|
input: ConformanceInput,
|
|
219
257
|
): Promise<void> {
|
|
220
258
|
const ts = now();
|
|
259
|
+
const processKey = input.processKey ?? null;
|
|
260
|
+
const reviewStatus = input.reviewStatus ?? "reviewed";
|
|
261
|
+
// Invariant: a `reviewing` row must be trackable. `pollUserTasks` skips rows without a
|
|
262
|
+
// `process_key` and the `instanceTracking` binding keys off `process_key`, so a `reviewing` row
|
|
263
|
+
// with a null key can never be surfaced to an operator nor cleared — it wedges forever. The
|
|
264
|
+
// conformance-record worker already guards its own call site, but `recordConformance` is a public
|
|
265
|
+
// API: reject the untrackable combination here too so no future caller can encode it.
|
|
266
|
+
if (reviewStatus === CONFORMANCE_REVIEWING_STATUS && processKey == null) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`recordConformance: ${planKey} would persist review_status='reviewing' with no process_key — ` +
|
|
269
|
+
"refusing to record an untrackable escalation that no poller or onTerminated binding can clear",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
221
272
|
const fields = {
|
|
222
273
|
status: input.status,
|
|
223
274
|
comment_url: input.commentUrl ?? null,
|
|
@@ -229,6 +280,8 @@ export async function recordConformance(
|
|
|
229
280
|
has_deviations: input.hasDeviations ? 1 : 0,
|
|
230
281
|
summary: input.summary ?? null,
|
|
231
282
|
report: input.report ?? null,
|
|
283
|
+
process_key: processKey,
|
|
284
|
+
review_status: reviewStatus,
|
|
232
285
|
updated_at: ts,
|
|
233
286
|
};
|
|
234
287
|
try {
|
|
@@ -238,3 +291,39 @@ export async function recordConformance(
|
|
|
238
291
|
await conformanceTbl(data).update(planKey, fields);
|
|
239
292
|
}
|
|
240
293
|
}
|
|
294
|
+
|
|
295
|
+
/** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
|
|
296
|
+
* `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
|
|
297
|
+
* closes) and stamp the disposition note into `summary` for the audit trail. Needed because the
|
|
298
|
+
* `retro` instance COMPLETES normally after the ack — `instanceTracking.onTerminated` only fires on a
|
|
299
|
+
* TERMINATED (crashed) instance, never a completed one, so nothing else would clear `reviewing`. */
|
|
300
|
+
export async function acknowledgeConformance(
|
|
301
|
+
data: DataLayer,
|
|
302
|
+
planKey: string,
|
|
303
|
+
note?: string | null,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const trimmed = typeof note === "string" && note.trim() ? note.trim() : null;
|
|
306
|
+
const existing = await conformanceTbl(data).get(planKey);
|
|
307
|
+
// Invariant: the ack task only fires after the escalation parked this exact `planKey` at
|
|
308
|
+
// `review_status='reviewing'`, so the row must exist. A missing row means a wrong/mismatched
|
|
309
|
+
// `planKey` (or unexpected DB state); silently returning would let the `retro` instance COMPLETE
|
|
310
|
+
// while the real conformance row stays stuck in `reviewing`, so `pollUserTasks` scans it forever.
|
|
311
|
+
// Fail loudly so the job retries/alerts instead of silently encoding the mismatch.
|
|
312
|
+
if (!existing) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`acknowledgeConformance: no plan_conformance row for ${planKey} — ` +
|
|
315
|
+
"refusing to settle a missing/mismatched escalation that would leave the real row stuck in 'reviewing'",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
// At-least-once worker semantics can retry `pr.conformance-ack` after a successful DB update; the
|
|
319
|
+
// row is already settled at `reviewed`, so short-circuit to keep the operation idempotent (a retry
|
|
320
|
+
// must not re-append a duplicate `Operator ack: …` block to the audit trail).
|
|
321
|
+
if (existing.review_status === "reviewed") return;
|
|
322
|
+
const prior = typeof existing.summary === "string" ? existing.summary : null;
|
|
323
|
+
const summary = trimmed ? (prior ? `${prior}\n\nOperator ack: ${trimmed}` : `Operator ack: ${trimmed}`) : prior;
|
|
324
|
+
await conformanceTbl(data).update(planKey, {
|
|
325
|
+
review_status: "reviewed",
|
|
326
|
+
summary,
|
|
327
|
+
updated_at: now(),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -11,6 +11,7 @@ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } fro
|
|
|
11
11
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
12
12
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
13
13
|
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
14
|
+
import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
|
|
14
15
|
|
|
15
16
|
interface Binding {
|
|
16
17
|
table: string;
|
|
@@ -117,3 +118,26 @@ test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)",
|
|
|
117
118
|
const b = bindingFor(await bindings(), "feature_runs");
|
|
118
119
|
assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
119
120
|
});
|
|
121
|
+
|
|
122
|
+
// The retro conformance-escalation lifecycle has exactly one in-flight `review_status` — `reviewing`
|
|
123
|
+
// (the only status `pollUserTasks` scans via `activeConformanceReviews`) — and settles to `reviewed`.
|
|
124
|
+
// Tie the manifest binding to the code's single source of truth (`CONFORMANCE_REVIEWING_STATUS`) so
|
|
125
|
+
// the two can't drift: if a future change adds a new in-flight status but forgets the manifest, a
|
|
126
|
+
// terminated retro instance would strand in `review_status='reviewing'` and never clear (issue #96
|
|
127
|
+
// class of drift — the exact gap Copilot flagged on this binding).
|
|
128
|
+
test("instanceTracking: plan_conformance activeStatuses is exactly the reviewing status (no drift)", async () => {
|
|
129
|
+
const b = bindingFor(await bindings(), "plan_conformance");
|
|
130
|
+
assertEquals([...(b.activeStatuses ?? [])].sort(), [CONFORMANCE_REVIEWING_STATUS]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// The settled status the reconciler flips a terminated row to (`onTerminated.set.review_status`)
|
|
134
|
+
// must NOT itself be listed active — otherwise `onTerminated` would leave the row scannable and the
|
|
135
|
+
// reconciler could clobber a settled run (mirrors the "excludes every terminal status" guards above).
|
|
136
|
+
test("instanceTracking: plan_conformance onTerminated status is not active", async () => {
|
|
137
|
+
const b = bindingFor(await bindings(), "plan_conformance");
|
|
138
|
+
const settled = b.onTerminated.set.review_status;
|
|
139
|
+
assert(
|
|
140
|
+
typeof settled === "string" && !b.activeStatuses?.includes(settled),
|
|
141
|
+
`onTerminated review_status "${String(settled)}" must not be listed active`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Upgrade smoke test (issue #357) — CI only ever migrates a FRESH database, so it never exercises the
|
|
2
|
+
// one state where the forward-only contract actually breaks: a *pre-existing* install upgrading to
|
|
3
|
+
// the current set. This materialises the migration set at the previous release tag, applies the
|
|
4
|
+
// current set on top the way the runtime does (ledger keyed by filename, skip-applied / apply-new),
|
|
5
|
+
// and asserts the upgrade completes cleanly.
|
|
6
|
+
//
|
|
7
|
+
// It covers the whole failure class on a real release boundary — a renamed/deleted migration
|
|
8
|
+
// re-running, non-idempotent DDL, or an ordering assumption — rather than any single instance. When
|
|
9
|
+
// the tag is unavailable (a shallow clone without tags) it skips with a diagnostic; CI checks out
|
|
10
|
+
// full history + tags (`fetch-depth: 0`), so the real upgrade path is always exercised there.
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
import {
|
|
15
|
+
applyMigrationSet,
|
|
16
|
+
previousReleaseTag,
|
|
17
|
+
readMigrationSetFromDisk,
|
|
18
|
+
readMigrationSetFromGit,
|
|
19
|
+
} from "#test-migrations";
|
|
20
|
+
|
|
21
|
+
test("upgrading a DB from the previous release's migrations to the current set applies cleanly", (t) => {
|
|
22
|
+
const tag = previousReleaseTag();
|
|
23
|
+
if (tag === null) {
|
|
24
|
+
t.skip("no previous release tag reachable (shallow clone without tags) — CI runs this with fetch-depth: 0");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const baseline = readMigrationSetFromGit(tag);
|
|
28
|
+
if (baseline === null) {
|
|
29
|
+
t.skip(`migration set at ${tag} unavailable — CI runs this with fetch-depth: 0`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const db = new DatabaseSync(":memory:");
|
|
34
|
+
// Stand up the DB exactly as the previous release left it...
|
|
35
|
+
applyMigrationSet(db, baseline);
|
|
36
|
+
const ledgerBefore = new Set(
|
|
37
|
+
(db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ...then upgrade in place to the current set. A rename/delete/non-idempotent migration throws here.
|
|
41
|
+
const current = readMigrationSetFromDisk();
|
|
42
|
+
applyMigrationSet(db, current);
|
|
43
|
+
|
|
44
|
+
// Every current migration is now recorded, and nothing the baseline applied went missing.
|
|
45
|
+
const ledgerAfter = new Set(
|
|
46
|
+
(db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
|
|
47
|
+
);
|
|
48
|
+
for (const file of current) {
|
|
49
|
+
assert(ledgerAfter.has(file.name), `current migration ${file.name} is recorded as applied after upgrade`);
|
|
50
|
+
}
|
|
51
|
+
for (const name of ledgerBefore) {
|
|
52
|
+
assert(ledgerAfter.has(name), `baseline migration ${name} remains in the ledger after upgrade`);
|
|
53
|
+
}
|
|
54
|
+
assertEquals(ledgerAfter.size, current.length, "the upgraded ledger holds exactly the current set");
|
|
55
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Regression guard for issue #357 — a migration renamed after it merged (`043_user_tasks_subject_
|
|
2
|
+
// title.sql` -> `046_…` in #316) re-runs against an already-migrated DB and aborts boot with
|
|
3
|
+
// "duplicate column name: subject_title", because the ledger is keyed by filename.
|
|
4
|
+
//
|
|
5
|
+
// This reproduces that exact break on the CURRENT migration set (RED), then proves
|
|
6
|
+
// `healMigrationLedger` reconciles the ledger so the upgrade completes cleanly (GREEN) — the recovery
|
|
7
|
+
// path for installs that migrated under the old name before the rename landed.
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { healMigrationLedger, RENAMED_MIGRATIONS } from "../app/migrationHeal.ts";
|
|
11
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
12
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
|
|
13
|
+
|
|
14
|
+
const NEW_NAME = "046_user_tasks_subject_title.sql";
|
|
15
|
+
const OLD_NAME = "043_user_tasks_subject_title.sql";
|
|
16
|
+
|
|
17
|
+
// A DB migrated fully with the CURRENT set, then rewound to model an install that applied the
|
|
18
|
+
// renamed migration under its OLD filename: the schema change is present, but the ledger records the
|
|
19
|
+
// old name — exactly the state a boot between #311 and #316 left behind.
|
|
20
|
+
function dbAppliedUnderOldName(): DatabaseSync {
|
|
21
|
+
const db = new DatabaseSync(":memory:");
|
|
22
|
+
applyMigrationSet(db, readMigrationSetFromDisk());
|
|
23
|
+
db.prepare("UPDATE _urban_migrations SET name=? WHERE name=?").run(OLD_NAME, NEW_NAME);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const applied = (db: DatabaseSync, name: string): boolean =>
|
|
28
|
+
(db.prepare("SELECT 1 AS one FROM _urban_migrations WHERE name=?").get(name) as
|
|
29
|
+
| { one: number }
|
|
30
|
+
| undefined) !== undefined;
|
|
31
|
+
|
|
32
|
+
test("RENAMED_MIGRATIONS pins the 043->046 user_tasks_subject_title rename", () => {
|
|
33
|
+
assertEquals(RENAMED_MIGRATIONS.get(NEW_NAME), OLD_NAME);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("#357 repro: re-applying the current set to a DB migrated under the old name aborts on duplicate column", () => {
|
|
37
|
+
const db = dbAppliedUnderOldName();
|
|
38
|
+
// The renamed migration is unapplied under its NEW name, so the runtime re-runs its
|
|
39
|
+
// `ALTER TABLE user_tasks ADD COLUMN subject_title` against a table that already has the column.
|
|
40
|
+
const err = assertThrows(() => applyMigrationSet(db, readMigrationSetFromDisk()));
|
|
41
|
+
assert(
|
|
42
|
+
/duplicate column name: subject_title/.test(err.message),
|
|
43
|
+
`expected a duplicate-column abort, got: ${err.message}`,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("heal reconciles the ledger so the upgrade completes cleanly", () => {
|
|
48
|
+
const db = dbAppliedUnderOldName();
|
|
49
|
+
|
|
50
|
+
const healed = healMigrationLedger(db);
|
|
51
|
+
assertEquals(healed, [NEW_NAME], "the new filename was aliased into the ledger");
|
|
52
|
+
assert(applied(db, NEW_NAME), "046 is now recorded as applied");
|
|
53
|
+
assert(applied(db, OLD_NAME), "the historical 043 ledger row is left intact");
|
|
54
|
+
|
|
55
|
+
// With the ledger reconciled, the renamed migration is skipped and the boot-time upgrade is clean.
|
|
56
|
+
const newly = applyMigrationSet(db, readMigrationSetFromDisk());
|
|
57
|
+
assertEquals(newly, [], "no migration re-runs after the heal");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("heal is idempotent and a no-op on a healthy DB", () => {
|
|
61
|
+
// A DB that applied the migration under its CURRENT name needs no healing.
|
|
62
|
+
const clean = new DatabaseSync(":memory:");
|
|
63
|
+
applyMigrationSet(clean, readMigrationSetFromDisk());
|
|
64
|
+
assertEquals(healMigrationLedger(clean), [], "clean install: nothing to heal");
|
|
65
|
+
|
|
66
|
+
// Healing twice changes nothing the second time.
|
|
67
|
+
const broken = dbAppliedUnderOldName();
|
|
68
|
+
assertEquals(healMigrationLedger(broken), [NEW_NAME]);
|
|
69
|
+
assertEquals(healMigrationLedger(broken), [], "second heal is a no-op");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("heal is a no-op on a fresh DB with no migration ledger", () => {
|
|
73
|
+
const fresh = new DatabaseSync(":memory:");
|
|
74
|
+
assertEquals(healMigrationLedger(fresh), [], "no ledger table => nothing to heal");
|
|
75
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// migrationHeal — recover installs broken by a migration that was RENAMED after it had already
|
|
2
|
+
// merged and applied (issue #357).
|
|
3
|
+
//
|
|
4
|
+
// The runtime (`@nanobpm/urban` applyMigrations) keys the `_urban_migrations` ledger by FILENAME, so
|
|
5
|
+
// a renamed migration is a *different* migration to the runner: it re-runs its DDL against a schema
|
|
6
|
+
// that already has the change, and a bare `ALTER TABLE ... ADD COLUMN` then aborts boot with
|
|
7
|
+
// "duplicate column". The direct hazard is now blocked forward by the immutability gate
|
|
8
|
+
// (`scripts/check-migrations.ts`), but installs that migrated under the OLD name *before* the rename
|
|
9
|
+
// landed are already stuck and need their ledger reconciled.
|
|
10
|
+
//
|
|
11
|
+
// The fix is a ledger-alias, NOT a schema change and NOT an edit to the renamed migration (editing a
|
|
12
|
+
// merged migration is itself forbidden by the immutability gate, and would silently no-op on every
|
|
13
|
+
// already-migrated DB anyway): if the old filename is recorded as applied, the new filename names the
|
|
14
|
+
// exact same forward-only change, so it is safe — and correct — to record the new filename as applied
|
|
15
|
+
// too. `healMigrationLedger` does that, guarded so it only ever fires on the broken state.
|
|
16
|
+
//
|
|
17
|
+
// `RENAMED_MIGRATIONS` is the single source of truth for known historical renames. It exists ONLY to
|
|
18
|
+
// heal the pre-gate past — the immutability gate prevents any new entry from ever being needed.
|
|
19
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
20
|
+
|
|
21
|
+
const MIGRATIONS_TABLE = "_urban_migrations";
|
|
22
|
+
|
|
23
|
+
// new filename -> old filename, for merged migrations that were renamed before the immutability gate
|
|
24
|
+
// existed. Each entry names the SAME forward-only change under two prefixes, so aliasing the ledger
|
|
25
|
+
// from the old name to the new is lossless.
|
|
26
|
+
//
|
|
27
|
+
// - `046_user_tasks_subject_title.sql` was merged as `043_user_tasks_subject_title.sql` (#311) and
|
|
28
|
+
// renumbered to 046 (#316) to break a prefix collision with `043_pr_epic_phase.sql`. Any DB that
|
|
29
|
+
// booted between those two PRs applied it as 043 and now re-runs it as 046 → "duplicate column
|
|
30
|
+
// name: subject_title" (issue #357).
|
|
31
|
+
export const RENAMED_MIGRATIONS: ReadonlyMap<string, string> = new Map([
|
|
32
|
+
["046_user_tasks_subject_title.sql", "043_user_tasks_subject_title.sql"],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Does the `_urban_migrations` ledger table exist? A fresh/never-migrated DB has nothing to heal. */
|
|
36
|
+
function ledgerExists(db: DatabaseSync): boolean {
|
|
37
|
+
return (
|
|
38
|
+
db
|
|
39
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
|
40
|
+
.get(MIGRATIONS_TABLE) !== undefined
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isApplied(db: DatabaseSync, name: string): boolean {
|
|
45
|
+
return db.prepare(`SELECT 1 AS one FROM ${MIGRATIONS_TABLE} WHERE name=?`).get(name) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reconcile the migration ledger for known historical renames, in place. For every rename where the
|
|
50
|
+
* OLD filename is recorded as applied but the NEW one is not, record the new filename as applied
|
|
51
|
+
* (aliasing the ledger) so the runtime stops re-running the renamed migration.
|
|
52
|
+
*
|
|
53
|
+
* Idempotent and safe on every state:
|
|
54
|
+
* - old applied, new missing -> alias inserted (the broken install; the only case that acts)
|
|
55
|
+
* - both applied -> no-op (overlay-drift install carrying both files)
|
|
56
|
+
* - only new applied -> no-op (clean install that never saw the old name)
|
|
57
|
+
* - neither applied / no ledger-> no-op (fresh DB)
|
|
58
|
+
*
|
|
59
|
+
* @returns the new filenames that were aliased in (empty when nothing needed healing).
|
|
60
|
+
*/
|
|
61
|
+
export function healMigrationLedger(db: DatabaseSync, now: () => Date = () => new Date()): string[] {
|
|
62
|
+
if (!ledgerExists(db)) return [];
|
|
63
|
+
const healed: string[] = [];
|
|
64
|
+
for (const [newName, oldName] of RENAMED_MIGRATIONS) {
|
|
65
|
+
if (isApplied(db, oldName) && !isApplied(db, newName)) {
|
|
66
|
+
db.prepare(`INSERT OR IGNORE INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
|
|
67
|
+
newName,
|
|
68
|
+
now().toISOString(),
|
|
69
|
+
);
|
|
70
|
+
healed.push(newName);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return healed;
|
|
74
|
+
}
|