@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.
- package/.bongos-core.json +71 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +46 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +33 -26
- package/docs/copy-registry.json +164 -92
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -43
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +5 -7
- package/modules/hall-ui/public/gate.js +215 -127
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -4
- package/modules/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +85 -41
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +28 -6
- package/tests/autonomy_builder_scope.mjs +1 -1
- package/tests/autonomy_fence_priority.mjs +5 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +73 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- 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
|
-
|
|
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.
|
|
525
|
-
//
|
|
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
|
|
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
|
-
[
|
|
616
|
+
[ids]
|
|
567
617
|
);
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// that
|
|
114
|
-
|
|
115
|
-
assert.
|
|
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 \|\|
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
assert.match(src, /
|
|
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, /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|