@bongos/core 1.20.81 → 1.20.83

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.
Files changed (45) hide show
  1. package/.bongos-core.json +71 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +46 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +33 -26
  10. package/docs/copy-registry.json +164 -92
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +58 -43
  13. package/modules/economy/earnings.js +117 -0
  14. package/modules/economy/module.json +1 -1
  15. package/modules/economy/routes/earnings.js +47 -0
  16. package/modules/hall-ui/public/gate.css +49 -0
  17. package/modules/hall-ui/public/gate.html +5 -7
  18. package/modules/hall-ui/public/gate.js +215 -127
  19. package/modules/hall-ui/public/profile-charts.js +206 -104
  20. package/modules/hall-ui/public/profile.css +23 -0
  21. package/modules/hall-ui/public/profile.js +3 -3
  22. package/modules/hall-ui/public/profile.states.json +8 -4
  23. package/modules/hall-ui/records/profile-roles.md +21 -0
  24. package/modules/lifecycle/db-goals.js +6 -0
  25. package/modules/lifecycle/db-versions.js +3 -0
  26. package/modules/lifecycle/done-when.js +85 -41
  27. package/modules/lifecycle/goal-close-sweep.js +34 -0
  28. package/modules/provisioning/app-status.js +17 -4
  29. package/modules/public-landing/public/projects.html +7 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +28 -0
  33. package/scripts/hall-preview/server.js +4 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/auto_satisfy_criteria.mjs +28 -6
  36. package/tests/autonomy_builder_scope.mjs +1 -1
  37. package/tests/autonomy_fence_priority.mjs +5 -3
  38. package/tests/autonomy_runner_identity.mjs +1 -1
  39. package/tests/criterion_goal_attach.mjs +5 -1
  40. package/tests/goal_close_cancels_requests.mjs +82 -0
  41. package/tests/goal_closure_invariants.mjs +73 -2
  42. package/tests/hall_profile_world.mjs +57 -58
  43. package/tests/profile_earnings.mjs +237 -0
  44. package/tests/projects_hub_app_status.mjs +17 -0
  45. package/tests/provisioning_app_status.mjs +25 -1
@@ -27,6 +27,7 @@ const { pool } = require('../../src/module-api');
27
27
  const { nonTerminalSql } = require('./task-status');
28
28
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
29
29
  const { executorFor } = require('./db-executor.js');
30
+ const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
30
31
  // BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
31
32
  // here creates no cycle (db-versions.js requires THIS file, which is why the
32
33
  // rule could not live beside closeVersion).
@@ -517,42 +518,91 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
517
518
  c.satisfied_by_task_id`,
518
519
  [scopedTaskId, scopedCriterionId]
519
520
  );
520
- if (criteria.length === 0) return { criteria: [], goals: [] };
521
+ // THE SHIP THAT FLIPS NO CRITERION STILL FINISHES ITS GOAL (task 1004513).
522
+ // Returning here unconditionally was a hole. When every criterion of a goal was
523
+ // satisfied BEFORE its last task shipped, the UPDATE above matches nothing — and
524
+ // the other writer (`POST /done-when/:id/satisfy` → achieveGoalIfComplete) had
525
+ // already run too early, while that task was still open, so it correctly held
526
+ // the goal open and then nobody ever re-asked. The goal sat `open` with every
527
+ // criterion satisfied and no work left, until a criterion was satisfied a second
528
+ // time purely to re-trigger the other door.
529
+ //
530
+ // So on the SCOPED ship path, re-check the shipping task's OWN goal against the
531
+ // same rule the cascade below applies. `exec` is load-bearing here for the same
532
+ // reason it is there: this task's flip to `shipped` is uncommitted, and on the
533
+ // module pool the task check would still read `confirmed` — non-terminal — and
534
+ // hold the goal open exactly as before.
535
+ //
536
+ // The UNSCOPED sweep keeps its cheap early return: it holds no task, so there is
537
+ // no candidate goal to name, and every goal it can close it closes through the
538
+ // cascade below.
539
+ if (criteria.length === 0) {
540
+ if (scopedTaskId == null) return { criteria: [], goals: [] };
541
+ const { rows: own } = await exec.query(
542
+ `SELECT goal_id FROM tasks WHERE id = $1::bigint AND goal_id IS NOT NULL`,
543
+ [scopedTaskId]
544
+ );
545
+ const goals = await achieveCompletedGoals(exec, own.map((r) => Number(r.goal_id)));
546
+ return { criteria: [], goals, versionsClosed: await autoCloseVersionsForGoals(exec, goals) };
547
+ }
521
548
 
522
549
  // Cascade. Deliberately re-derived from the table rather than from `criteria`:
523
550
  // a goal completes when ALL of its criteria are satisfied, and the ones that
524
- // closed in THIS call may not be all of them. Mirrors db.achieveGoalIfComplete
525
- // exactly — open goals only, >=1 criterion, zero unsatisfied, AND no unfinished
526
- // task (BV1.R09, task 1003596) — but on `exec`, so it sees the flips above
527
- // inside the same transaction.
528
- //
529
- // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This
530
- // is the path that actually fires on every ship (`db-ship.js`) and every
531
- // reconciler tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the
532
- // manual `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate —
533
- // collapsing them into one helper creates a require cycle (db-goals requires
534
- // done-when) — so the rule is written twice on purpose and both copies must
535
- // move together, or a goal closes correctly through one door and wrongly
536
- // through the other. ADR 0250 D2.
537
- //
538
- // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
539
- // to `shipped` is uncommitted at this point in the ship transaction; on the
540
- // module pool the subquery would still see it as `confirmed` — non-terminal —
541
- // and refuse to close a goal whose last task just landed. Every goal would then
542
- // need a second, later trigger to close at all.
543
- //
544
- // Shipping is NOT blocked by any of this: the ship's own writes are untouched
545
- // and this UPDATE simply matches no row, leaving the goal open.
546
- //
547
- // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
548
- // (goal_id), so this is a small index scan per candidate goal, and the candidate
549
- // set is only the goals whose criteria just closed in this call. (Raised in
550
- // review off docs/architecture.md, whose tasks index list predated 228 and has
551
- // been corrected.)
551
+ // closed in THIS call may not be all of them. The statement itself is
552
+ // achieveCompletedGoals, below.
552
553
  const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
553
554
  // No goal achieved, so no version can have just become closable.
554
555
  if (goalIds.length === 0) return { criteria, goals: [], versionsClosed: [] };
555
- const { rows: goals } = await exec.query(
556
+ const goals = await achieveCompletedGoals(exec, goalIds);
557
+ // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
558
+ // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
559
+ // above that have not committed yet — a check on the pool would never close the
560
+ // version on the ship that actually finished it.
561
+ //
562
+ // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
563
+ // close must not be able to fail a builder's ship. The accepted failure is a
564
+ // version left un-closed, which the reconciler's next unscoped sweep heals.
565
+ const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
566
+ return { criteria, goals, versionsClosed };
567
+ }
568
+
569
+ // achieveCompletedGoals — the ONE statement that flips a goal open→achieved on
570
+ // the ship/sweep side. Written once because autoSatisfyShippedCriteria now enters
571
+ // it from two places — the criteria this call just closed, and (task 1004513) the
572
+ // shipping task's own goal when it closed none — and two copies of this rule
573
+ // would be two things to keep in step. Takes the caller's `exec` and the candidate
574
+ // goal ids; returns the rows that actually flipped (usually none).
575
+ //
576
+ // Mirrors db.achieveGoalIfComplete exactly — open goals only, >=1 criterion, zero
577
+ // unsatisfied, AND no unfinished task (BV1.R09, task 1003596).
578
+ //
579
+ // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This is
580
+ // the path that actually fires on every ship (`db-ship.js`) and every reconciler
581
+ // tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the manual
582
+ // `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate — collapsing
583
+ // them into one helper creates a require cycle (db-goals requires done-when) — so
584
+ // the rule is written twice on purpose and both copies must move together, or a
585
+ // goal closes correctly through one door and wrongly through the other. ADR 0250
586
+ // D2.
587
+ //
588
+ // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
589
+ // to `shipped` is uncommitted at this point in the ship transaction; on the module
590
+ // pool the subquery would still see it as `confirmed` — non-terminal — and refuse
591
+ // to close a goal whose last task just landed. Every goal would then need a
592
+ // second, later trigger to close at all.
593
+ //
594
+ // Shipping is NOT blocked by any of this: the ship's own writes are untouched and
595
+ // this UPDATE simply matches no row, leaving the goal open.
596
+ //
597
+ // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
598
+ // (goal_id), so this is a small index scan per candidate goal, and the candidate
599
+ // set is only the goals this call could possibly have completed. (Raised in review
600
+ // off docs/architecture.md, whose tasks index list predated 228 and has been
601
+ // corrected.)
602
+ async function achieveCompletedGoals(exec, goalIds) {
603
+ const ids = (goalIds || []).filter((g) => g != null).map(Number);
604
+ if (ids.length === 0) return [];
605
+ const { rows } = await exec.query(
556
606
  `UPDATE goals g
557
607
  SET status = 'achieved', updated_at = now()
558
608
  WHERE g.id = ANY($1::bigint[])
@@ -563,18 +613,12 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
563
613
  AND ${nonTerminalSql()}
564
614
  AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')})
565
615
  RETURNING g.id, g.title, g.version_id, g.status`,
566
- [goalIds]
616
+ [ids]
567
617
  );
568
- // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
569
- // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
570
- // above that have not committed yet — a check on the pool would never close the
571
- // version on the ship that actually finished it.
572
- //
573
- // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
574
- // close must not be able to fail a builder's ship. The accepted failure is a
575
- // version left un-closed, which the reconciler's next unscoped sweep heals.
576
- const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
577
- return { criteria, goals, versionsClosed };
618
+ // Task 1003660: an achieved goal takes no new members — close its pending asks
619
+ // on the same executor, so the cancel rides the ship transaction.
620
+ await cancelPendingMembershipRequests(exec, rows.map((g) => g.id));
621
+ return rows;
578
622
  }
579
623
 
580
624
  // describeAutoClose — the one-line summary of what a close actually closed, or
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ // goal-close-sweep.js — closing a goal cancels its outstanding invites + join
3
+ // requests (task 1003660, from idea 1000787).
4
+ //
5
+ // Task 1002887 added the accept-time `goal_not_open` re-check, which refuses a
6
+ // stale invite when someone tries to accept it — but until then the row kept
7
+ // listing in the invitee's inbox as something they could act on. A goal that has
8
+ // left `open` (achieved or archived) can take no new members, so its pending rows
9
+ // are dead; this closes them at the moment the goal closes instead.
10
+ //
11
+ // BOTH kinds ('invite' and 'request') are cancelled, with the same 'cancelled'
12
+ // terminal state addGoalMember uses (core_188: "withdrawn/rescinded"). 'rejected'
13
+ // would stamp a decision on someone nobody made one about, and 'accepted' stays
14
+ // reserved for a flow that actually ran. resolved_by is left NULL: no person
15
+ // resolved it, the goal closing did.
16
+ //
17
+ // Takes the CALLER'S executor, never the module pool, and requires nothing — so
18
+ // the ship-time cascade in done-when.js (which cannot require db-goals.js without
19
+ // a cycle) and every archive path share one statement, and the cancel commits or
20
+ // rolls back with the status flip it belongs to.
21
+
22
+ async function cancelPendingMembershipRequests(exec, goalIds) {
23
+ const ids = (Array.isArray(goalIds) ? goalIds : [goalIds]).filter((g) => g != null).map(Number);
24
+ if (ids.length === 0) return 0;
25
+ const { rowCount } = await exec.query(
26
+ `UPDATE goal_membership_requests
27
+ SET status = 'cancelled', resolved_at = now(), updated_at = now()
28
+ WHERE goal_id = ANY($1::bigint[]) AND status = 'pending'`,
29
+ [ids]
30
+ );
31
+ return rowCount ?? 0;
32
+ }
33
+
34
+ module.exports = { cancelPendingMembershipRequests };
@@ -23,27 +23,40 @@ const RENDER_URL_RE = /^https:\/\/[a-z0-9-]{1,63}\.onrender\.com\/?$/;
23
23
 
24
24
  // The stages the owner sees, in order. `failed` is not a stage: it is the setup stopping.
25
25
  const STAGES = ['creating', 'building', 'live'];
26
+
27
+ // How long a build may go unanswered before the owner stops being told it is building
28
+ // (task 1004502). Render can give up on a deploy — project 43's timed out in 15 minutes —
29
+ // and with the key gone (ADR 0327) the platform cannot read that verdict. So past this the
30
+ // stage is `quiet`: not a failure (we cannot see one), but no longer a promise, with the
31
+ // way to Render's own deploy history. The poller keeps knocking, so a late answer still
32
+ // turns it `live`. Measured from the runner's last write (`updatedAt`, provision-render.js),
33
+ // which is when `deploying` or `linked` began — the poller's `live` write never touches it.
34
+ const QUIET_AFTER_MS = 20 * 60_000;
26
35
  const STAGE_OF = { requested: 'creating', creating: 'creating', deploying: 'building', linked: 'building', live: 'live' };
27
36
 
28
37
  function serviceLinks(serviceId) {
29
38
  if (!SERVICE_ID_RE.test(String(serviceId || ''))) return null;
30
39
  const base = `${DASHBOARD}/web/${serviceId}`;
31
- return { service: base, env: `${base}/env` };
40
+ return { service: base, env: `${base}/env`, deploys: `${base}/events` };
32
41
  }
33
42
 
34
43
  function appUrl(url) { return RENDER_URL_RE.test(String(url || '')) ? String(url).replace(/\/+$/, '') : null; }
35
44
 
36
45
  // The owner's view of app_host: the stage, the address, the links, the failure sentence,
37
46
  // and the answers a retry re-sends (never the key — there is none to send).
38
- function appStatus(appHost, { keyWaiting = false } = {}) {
47
+ function appStatus(appHost, { keyWaiting = false, now = Date.now() } = {}) {
39
48
  const h = appHost || null;
40
49
  if (!h || h.platform !== 'render') return null;
41
50
  if (h.state === 'disconnected') return { platform: 'render', state: 'disconnected', stage: null };
42
51
  const failed = h.state === 'failed';
52
+ const stage = failed ? null : (STAGE_OF[h.state] || 'creating');
53
+ const since = Date.parse(h.updatedAt || '');
54
+ const quiet = stage === 'building' && Number.isFinite(since) && now - since > QUIET_AFTER_MS;
43
55
  return {
44
56
  platform: 'render',
45
57
  state: h.state || 'requested',
46
- stage: failed ? null : (STAGE_OF[h.state] || 'creating'),
58
+ stage: quiet ? 'quiet' : stage,
59
+ quiet,
47
60
  // an intent that still holds the lent key has not run yet, so the owner waits rather
48
61
  // than retries
49
62
  waiting: !!keyWaiting,
@@ -80,4 +93,4 @@ function retryAnswers(h) {
80
93
  return r;
81
94
  }
82
95
 
83
- module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, RENDER_URL_RE, SERVICE_ID_RE };
96
+ module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, QUIET_AFTER_MS, RENDER_URL_RE, SERVICE_ID_RE };
@@ -2333,6 +2333,13 @@ summary{min-height:24px;padding:3px 0;}
2333
2333
  v.hint.setAttribute('role', 'status');
2334
2334
  v.retryForm.appendChild(v.hint);
2335
2335
  v.root.appendChild(v.retryForm);
2336
+ } else if (app.stage === 'quiet') {
2337
+ /* Past the time box (task 1004502): Render may have given up, and with the key gone we
2338
+ cannot see its verdict — so no stage is claimed, the owner is sent to Render's own
2339
+ deploy history, and the watch keeps asking in case the app still comes up. */
2340
+ v.root.appendChild(appPara(['Your app hasn’t answered for over 20 minutes. Render may have stopped the deploy — its deploy history says why.']));
2341
+ if (app.links && app.links.deploys) v.root.appendChild(appPara([appLink(app.links.deploys, 'See its deploys on Render')]));
2342
+ v.root.appendChild(appPara(['If it starts answering, this changes to Live by itself.']));
2336
2343
  } else {
2337
2344
  var at = APP_STAGES.map(function (s) { return s[0]; }).indexOf(app.stage);
2338
2345
  var ol = appEl('ol', 'appStages');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.81",
3
+ "version": "1.20.83",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.81",
9
+ "version": "1.20.83",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.81",
3
+ "version": "1.20.83",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8631,5 +8631,33 @@
8631
8631
  "id": "1004501",
8632
8632
  "text": "Each builder now chooses which goals their own Autobongos runner works, in Settings, from the goals the project allows. A builder who chooses nothing gets no unattended work. Who may run a runner at all is now a project permis"
8633
8633
  }
8634
+ ],
8635
+ "1.20.82": [
8636
+ {
8637
+ "id": "1004502",
8638
+ "text": "When an app on Render never comes up, its project card no longer says 'building' forever: after 20 minutes it says the app hasn't answered and links straight to the app's deploy history on Render."
8639
+ },
8640
+ {
8641
+ "id": "1004452",
8642
+ "text": "We tested the deploy self-repair on a real throwaway project on the server. When a project has leftover unsaved changes, the platform now fixes them itself and the update goes through. When it hits something it cannot fix, it"
8643
+ },
8644
+ {
8645
+ "id": "1004436",
8646
+ "text": "Every profile now shows an earnings chart for anyone who has earned credits, even artists and ideators who have never shipped a task (before, they got no chart at all). Beside the line, a \"where it came from\" list shows each k"
8647
+ },
8648
+ {
8649
+ "id": "1003660",
8650
+ "text": "Closing a goal now clears its pending invites and join requests, so a stale invite disappears from the invitee's inbox instead of sitting there until someone tries to accept it."
8651
+ },
8652
+ {
8653
+ "id": "1004509",
8654
+ "text": "The Autobongos runner panel on the Gate page is now much lighter to read. While anything is missing it shows a short checklist saying what to do next; once everything is ready it shrinks to one status line with an on/off switc"
8655
+ }
8656
+ ],
8657
+ "1.20.83": [
8658
+ {
8659
+ "id": "1004513",
8660
+ "text": "A goal now finishes by itself when its last task ships. Before, if someone ticked off all of a goal's done-when criteria while one task was still being worked on, the goal stayed open forever once that task landed — it took ti"
8661
+ }
8634
8662
  ]
8635
8663
  }
@@ -118,6 +118,10 @@ const FIXTURE_FALLBACKS = [
118
118
  // the profile's drachmae chart reads the credits ledger (task 1003491): one
119
119
  // ledger fixture serves your own (/me/credits, exact match) and any other id.
120
120
  [/^builders\/\d+\/credits$/, 'me__credits'],
121
+ // the profile's earnings figure reads earnings by day and kind (task 1004436):
122
+ // one engineer's fixture serves any id; the artist and the ideator who never
123
+ // shipped a task (2001, 2002) carry their own, beside an empty activity record.
124
+ [/^builders\/\d+\/earnings$/, 'builders__earnings'],
121
125
  // the profile's per-craft activity log (task 1003281). One fixture per SECTION
122
126
  // rather than per id, because the section is what changes the row shape; the
123
127
  // craft and the page window are query string, which fixture matching ignores.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.81'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -107,12 +107,14 @@ await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', (
107
107
  await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
108
108
  const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
109
109
  const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
110
- // Three since BV1.R16 (task 1003603): the criterion update, the goal cascade,
111
- // and the version auto-close — which is the point, because the third must ride
112
- // the SAME exec as the first two or a version would never close on the ship
113
- // that finished it.
114
- assert.equal(exec.calls.length, 3, 'criterion update + goal cascade + version auto-close');
115
- assert.match(exec.calls[2].sql, /UPDATE versions v/, 'and the third is the auto-close, on exec');
110
+ // Four: the criterion update, the goal cascade, the pending-request sweep for the
111
+ // goals it just achieved (task 1003660), and the version auto-close (BV1.R16,
112
+ // task 1003603) — every one must ride the SAME exec or a rolled-back ship could
113
+ // leave a half-applied close (or a version that never closes on the ship that
114
+ // finished it).
115
+ assert.equal(exec.calls.length, 4, 'criterion update + goal cascade + request sweep + version auto-close');
116
+ assert.match(exec.calls[2].sql, /UPDATE goal_membership_requests/, 'the sweep rides exec');
117
+ assert.match(exec.calls[3].sql, /UPDATE versions v/, 'and the last is the auto-close, on exec');
116
118
  assert.deepEqual(out.criteria, [CRIT]);
117
119
  assert.deepEqual(out.goals.map((g) => g.id), [77]);
118
120
  const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
@@ -156,6 +158,26 @@ await test('NO-OP: nothing closed means no cascade query at all', async () => {
156
158
  assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
157
159
  });
158
160
 
161
+ await test('NO-OP (scoped): a ship that flips no criterion still re-asks its OWN goal (task 1004513)', async () => {
162
+ // When every criterion was satisfied BEFORE the last task shipped, the ship
163
+ // flips none — and the early return above used to mean nothing re-checked the
164
+ // goal, which then sat `open` with all its work done (goal 1000119, 2026-10-01).
165
+ const exec = fakeExec(
166
+ { rows: [] },
167
+ { rows: [{ goal_id: 77 }] },
168
+ { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] }
169
+ );
170
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
171
+ assert.deepEqual(out.criteria, [], 'no criterion moved — ADR 0183 is untouched');
172
+ assert.deepEqual(out.goals.map((g) => g.id), [77], 'but the goal closes on the ship that finished it');
173
+ assert.match(exec.calls[1].sql, /SELECT goal_id FROM tasks WHERE id = \$1::bigint/, 'the shipping task names the candidate');
174
+ assert.deepEqual(exec.calls[1].params, [5]);
175
+ assert.match(String(exec.calls[2].sql), /UPDATE goals g/, 'and it runs the SAME cascade statement');
176
+ assert.match(String(exec.calls[2].sql).replace(/\s+/g, ' '), /NOT EXISTS \(SELECT 1 FROM tasks t WHERE t\.goal_id = g\.id/,
177
+ 'carrying R09 — this entry may not be a looser rule than the cascade');
178
+ assert.deepEqual(exec.calls[2].params, [[77]], 'scoped to that one goal, nothing wider');
179
+ });
180
+
159
181
  await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
160
182
  const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
161
183
  await autoSatisfyShippedCriteria(exec, { taskId: 5 });
@@ -457,7 +457,7 @@ test('autonomy.run is an operational Metic permission, granted to Metic and Arch
457
457
 
458
458
  test('the owner page draws the PROJECT fence and pauses one builder; the settings panel writes the caller’s own picks', () => {
459
459
  const gate = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
460
- assert.match(gate, /fence\.project \|\| fence/, 'the switch panel must not draw the owner’s own narrowed picks as the project list');
460
+ assert.match(gate, /const fence = raw\.project \|\| raw;/, 'the switch panel must not draw the owner’s own narrowed picks as the project list');
461
461
  assert.match(gate, /\/autonomy\/runners\/all/);
462
462
  assert.match(gate, /\/autonomy\/builders\/\$\{encodeURIComponent\(id\)\}\/pause/);
463
463
  const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.html'), 'utf8');
@@ -206,8 +206,10 @@ test('the gate page renders the priority, lists it first, and posts to the prior
206
206
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
207
207
  // The PROJECT fence's priority (task 1004501): GET /autonomy/fence now answers
208
208
  // with the caller's narrowed fence, and the switch panel draws `project`.
209
- assert.match(src, /renderFenceGoals\(project\.goals, project\.priority_goal_id\)/, 'the page must pass the priority it read');
209
+ // The chips (task 1004509) are drawn from that project fence.
210
+ assert.match(src, /goalChipsHtml\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
210
211
  assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
211
- assert.match(src, /Make priority/);
212
- assert.match(src, /Clear priority/);
212
+ // The star on a chip sets it, and the star on the priority chip clears it.
213
+ assert.match(src, /Work this goal first/);
214
+ assert.match(src, /Worked first — click to clear/);
213
215
  });
@@ -208,7 +208,7 @@ test('the migration adds the four columns, additively', () => {
208
208
 
209
209
  test('the gate card draws the PC, the account, the code and the restarting label', () => {
210
210
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
211
- assert.match(src, /restarting: 'Restarting'/);
211
+ assert.match(src, /Restarting to pick up new code/);
212
212
  assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
213
213
  assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
214
214
  assert.match(src, /behind main/);
@@ -557,9 +557,13 @@ await test('setGoalStatus writes on the transaction client (task 1003840)', asyn
557
557
  const seen = [];
558
558
  const fakeClient = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 3, status: 'achieved' }] }; } };
559
559
  await realGoals.setGoalStatus({ goalId: 3, status: 'achieved' }, { client: fakeClient });
560
- assert.equal(seen.length, 1, 'the UPDATE reached the injected client');
560
+ // Two since task 1003660: the status flip, then the sweep of the goal's pending
561
+ // invites/requests. BOTH must land on the injected client, or the sweep would
562
+ // reintroduce the very pool-vs-transaction deadlock this test guards.
563
+ assert.equal(seen.length, 2, 'the UPDATE and the request sweep reached the injected client');
561
564
  assert.match(seen[0].sql, /UPDATE goals/);
562
565
  assert.deepEqual(seen[0].params, [3, 'achieved']);
566
+ assert.match(seen[1].sql, /UPDATE goal_membership_requests/);
563
567
  });
564
568
 
565
569
  // The wrapper installed above is removed, so nothing after this file's own tests
@@ -0,0 +1,82 @@
1
+ // tests/goal_close_cancels_requests.mjs
2
+ //
3
+ // Task 1003660: closing a goal (achieved / archived) cancels its pending invites
4
+ // and join-requests, so a stale row leaves the inbox instead of waiting to be
5
+ // refused at accept time. DB-free: a SQL-aware fake records the statements.
6
+ //
7
+ // Run: node tests/goal_close_cancels_requests.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
12
+
13
+ process.env.NODE_ENV = 'test';
14
+ const require = createRequire(import.meta.url);
15
+ const { setGoalStatus } = require('../modules/lifecycle/db-goals.js');
16
+ const { cancelPendingMembershipRequests } = require('../modules/lifecycle/goal-close-sweep.js');
17
+
18
+ const { test, summary } = makeRunner();
19
+
20
+ function fakePool(handler) {
21
+ const c = makeSqlAwareClient(handler);
22
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
23
+ }
24
+ const norm = (q) => String(q.sql).replace(/\s+/g, ' ');
25
+ const sweeps = (pool) => pool.queries.filter((q) => /^UPDATE goal_membership_requests/.test(norm(q)));
26
+ const goalRow = (status) => ({ id: 7, status });
27
+ const flip = (status) => (s) => (/^UPDATE goals/.test(s) ? { rows: [goalRow(status)] } : null);
28
+
29
+ await test('cancelPendingMembershipRequests: cancels BOTH kinds, pending only, for the given goals', async () => {
30
+ const pool = fakePool(() => ({ rows: [], rowCount: 2 }));
31
+ const n = await cancelPendingMembershipRequests(pool, [7, '8']);
32
+ assert.equal(n, 2);
33
+ const [q] = sweeps(pool);
34
+ assert.match(norm(q), /SET status = 'cancelled'/);
35
+ assert.match(norm(q), /status = 'pending'/);
36
+ assert.doesNotMatch(norm(q), /kind\s*=/, 'neither the invite nor the request arm is excluded');
37
+ assert.deepEqual(q.params, [[7, 8]]);
38
+ });
39
+
40
+ await test('cancelPendingMembershipRequests: no ids → no statement', async () => {
41
+ const pool = fakePool(() => ({ rows: [] }));
42
+ assert.equal(await cancelPendingMembershipRequests(pool, []), 0);
43
+ assert.equal(await cancelPendingMembershipRequests(pool, null), 0);
44
+ assert.equal(pool.queries.length, 0);
45
+ });
46
+
47
+ for (const status of ['achieved', 'archived']) {
48
+ await test(`setGoalStatus(${status}) sweeps the goal's pending requests`, async () => {
49
+ const pool = fakePool(flip(status));
50
+ await setGoalStatus({ goalId: 7, status }, { pool });
51
+ assert.equal(sweeps(pool).length, 1);
52
+ assert.deepEqual(sweeps(pool)[0].params, [[7]]);
53
+ });
54
+ }
55
+
56
+ await test('setGoalStatus(open) — a reopen — sweeps nothing', async () => {
57
+ const pool = fakePool(flip('open'));
58
+ await setGoalStatus({ goalId: 7, status: 'open' }, { pool });
59
+ assert.equal(sweeps(pool).length, 0);
60
+ });
61
+
62
+ await test('setGoalStatus on an unknown goal sweeps nothing', async () => {
63
+ const pool = fakePool(() => ({ rows: [] }));
64
+ await setGoalStatus({ goalId: 99, status: 'archived' }, { pool });
65
+ assert.equal(sweeps(pool).length, 0);
66
+ });
67
+
68
+ await test('every in-module writer that archives or achieves a goal also sweeps', async () => {
69
+ const { readFileSync } = await import('node:fs');
70
+ const read = (f) => readFileSync(new URL(`../modules/lifecycle/${f}`, import.meta.url), 'utf8');
71
+ const count = (s, re) => (s.match(re) || []).length;
72
+ const closes = /status = 'archived'|status = 'achieved'/g;
73
+ for (const f of ['db-goals.js', 'db-versions.js', 'done-when.js']) {
74
+ const src = read(f);
75
+ // setGoalStatus takes the status as a parameter (covered above); every
76
+ // literal close in these files must have a sweep call.
77
+ assert.ok(count(src, /cancelPendingMembershipRequests\(/g) >= count(src, closes),
78
+ `${f}: each literal goal close needs a cancelPendingMembershipRequests call`);
79
+ }
80
+ });
81
+
82
+ summary();
@@ -95,6 +95,14 @@ function makeExec(w) {
95
95
  return { rows: closed.map((c) => ({ ...c, satisfied_by_task_id: scoped })) };
96
96
  }
97
97
 
98
+ // (1b) task 1004513: the shipping task's OWN goal, asked only when the ship
99
+ // flipped no criterion — the case where every criterion was already
100
+ // satisfied and the goal has nothing left but this task.
101
+ if (/SELECT goal_id FROM tasks WHERE id = \$1/.test(s)) {
102
+ const t = taskById(Number(params[0]));
103
+ return { rows: t && t.goal_id != null ? [{ goal_id: t.goal_id }] : [] };
104
+ }
105
+
98
106
  // (2) the cascade. NOT hand-coded: the model reads the statement it was
99
107
  // actually handed and honours only the clauses PRESENT in it — including
100
108
  // the terminal set, parsed out of the SQL rather than imported. That is
@@ -256,10 +264,73 @@ test('every statement goes through the caller exec — none reaches the module p
256
264
  // nothing reaches the module pool — is exactly what makes auto-close correct on
257
265
  // a ship: the goal it keys on was flipped in this uncommitted transaction, and
258
266
  // a pool read would not see it.
259
- assert.equal(exec.calls.length, 3, 'the criterion close, the cascade and the version auto-close, all on exec');
267
+ // Task 1003660 added a fourth: the sweep of the achieved goal's pending
268
+ // invites/requests, between the cascade and the version auto-close.
269
+ assert.equal(exec.calls.length, 4, 'the criterion close, the cascade, the request sweep and the version auto-close, all on exec');
260
270
  assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
261
271
  assert.match(exec.calls[1].sql, /UPDATE goals g/);
262
- assert.match(exec.calls[2].sql, /UPDATE versions v/);
272
+ assert.match(exec.calls[2].sql, /UPDATE goal_membership_requests/);
273
+ assert.match(exec.calls[3].sql, /UPDATE versions v/);
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 2b. The ship that flips NO criterion still finishes its goal (task 1004513)
278
+ //
279
+ // The hole this pins: satisfy every criterion of a goal while one of its tasks is
280
+ // still open. The manual door (achieveGoalIfComplete, off POST /done-when/:id/
281
+ // satisfy) runs then and correctly declines — a task is unfinished. When that
282
+ // task later ships, the cascade flips no criterion (they are already satisfied),
283
+ // and before this fix the no-criteria early return meant the goal was never
284
+ // re-asked: it sat `open` with everything done. Live on goal 1000119, 2026-10-01.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ test('a goal whose criteria were ALL satisfied before its last task shipped still closes', async () => {
288
+ const w = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] });
289
+ const exec = makeExec(w);
290
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 200 });
291
+ assert.deepEqual(out.criteria, [], 'the ship flips no criterion — they were satisfied already');
292
+ assert.equal(out.goals.length, 1, 'and the goal still reaches achieved');
293
+ assert.equal(w.goals[0].status, 'achieved');
294
+ // The wiring, not just the verdict: it asks for the shipping task's own goal
295
+ // and then runs the SAME cascade statement, on the same exec.
296
+ assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
297
+ assert.match(exec.calls[1].sql, /SELECT goal_id FROM tasks WHERE id = \$1/);
298
+ assert.match(exec.calls[2].sql, /UPDATE goals g/);
299
+ assert.deepEqual(exec.calls[2].params, [[77]], 'scoped to the shipping task\'s goal, nothing wider');
300
+ assert.match(exec.calls[3].sql, /UPDATE goal_membership_requests/, 'an achieved goal closes its pending asks on this path too (task 1003660)');
301
+ assert.match(exec.calls[4].sql, /UPDATE versions v/, 'and the version close still rides along');
302
+ });
303
+
304
+ for (const status of HOLDS_OPEN) {
305
+ test(`the no-criterion re-check honours the same rule: a '${status}' sibling still holds the goal open`, async () => {
306
+ const w = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
307
+ const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
308
+ assert.deepEqual(out.goals, [], 'the re-check is the rule, not a bypass of it');
309
+ assert.equal(w.goals[0].status, 'open');
310
+ });
311
+ }
312
+
313
+ test('the no-criterion re-check reads through the exec it was handed', async () => {
314
+ // Same instant, two views. Inside the ship transaction task 200 is already
315
+ // `shipped`; on the module pool it is still `confirmed` — non-terminal — and
316
+ // the goal must not close. The pair is what proves the read goes through exec.
317
+ const inTxn = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] });
318
+ const outsideTxn = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [{ status: 'confirmed' }] });
319
+ assert.equal((await autoSatisfyShippedCriteria(makeExec(inTxn), { taskId: 200 })).goals.length, 1);
320
+ assert.deepEqual((await autoSatisfyShippedCriteria(makeExec(outsideTxn), { taskId: 200 })).goals, []);
321
+ assert.equal(outsideTxn.goals[0].status, 'open');
322
+ });
323
+
324
+ test('a goal-less task closes nothing, and the UNSCOPED sweep keeps its cheap early return', async () => {
325
+ const orphan = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED({ goal_id: null })] });
326
+ const orphanExec = makeExec(orphan);
327
+ assert.deepEqual((await autoSatisfyShippedCriteria(orphanExec, { taskId: 200 })).goals, []);
328
+ assert.equal(orphanExec.calls.length, 2, 'it asks for the goal, finds none, and stops');
329
+
330
+ const sweep = makeExec(world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] }));
331
+ const out = await autoSatisfyShippedCriteria(sweep, {});
332
+ assert.deepEqual(out, { criteria: [], goals: [] }, 'the sweep holds no task, so it has no goal to name');
333
+ assert.equal(sweep.calls.length, 1, 'and issues no second statement');
263
334
  });
264
335
 
265
336
  // ---------------------------------------------------------------------------