@bongos/core 1.20.80 → 1.20.82
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 +98 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +245 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +84 -71
- package/docs/copy-registry.json +271 -145
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +139 -119
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +136 -0
- package/modules/autonomy/fence.js +88 -3
- package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
- package/modules/autonomy/routes/autonomy.js +123 -3
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/government/catalog.js +9 -1
- package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +8 -7
- package/modules/hall-ui/public/gate.js +272 -115
- 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/public/settings-autobongos.js +156 -0
- package/modules/hall-ui/public/settings.html +19 -0
- package/modules/hall-ui/public/settings.js +1 -0
- 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 +4 -0
- 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 +8 -6
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +7 -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 +5 -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
|
@@ -161,6 +161,9 @@ function classifyEvent(row = {}) {
|
|
|
161
161
|
|
|
162
162
|
if (event === 'fenced') {
|
|
163
163
|
if (code === 'kill_switch') return { class: 'stopped', detail: row.reason || 'an owner stopped the runner' };
|
|
164
|
+
// The per-builder stops (task 1004501) are the same kind of fact as the project
|
|
165
|
+
// switch: a person decided, and probing will not change their mind.
|
|
166
|
+
if (code === 'builder_paused' || code === 'not_permitted') return { class: 'stopped', detail: row.reason || 'this builder’s runner is stopped' };
|
|
164
167
|
if (code === 'grader_unavailable') return { class: 'transient', detail: 'the grader is unavailable, and it is the only review there is' };
|
|
165
168
|
if (code === 'fence_unreadable') return { class: 'transient', detail: 'the fence could not be read' };
|
|
166
169
|
// no_allowlist / goal_not_allowlisted / protected_path: the fence is working
|
package/modules/autonomy/db.js
CHANGED
|
@@ -157,6 +157,138 @@ async function disallowGoal(goalId) {
|
|
|
157
157
|
return { removed: rowCount > 0 };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// ── each builder's runner scope (task 1004501) ───────────────────────────────
|
|
161
|
+
//
|
|
162
|
+
// The builder's half of the fence: which allowlisted goals THEIR runner works, and
|
|
163
|
+
// whether the owner has paused it. The composition with the project fence is pure
|
|
164
|
+
// and lives in ../fence.js (effectiveFence); these stay dumb.
|
|
165
|
+
|
|
166
|
+
// readBuilderScope — one builder's picks and pause, or null when they have neither
|
|
167
|
+
// (which effectiveFence reads as "no goals", by owner ruling). The picks join the
|
|
168
|
+
// allowlist so a pick can never outlive its goal there, on top of the CASCADE.
|
|
169
|
+
async function readBuilderScope(builderId) {
|
|
170
|
+
const [scope, goals] = await Promise.all([
|
|
171
|
+
pool.query('SELECT owner_paused, owner_paused_reason, owner_paused_by, updated_at FROM autonomy_builder_scope WHERE builder_id = $1', [builderId]),
|
|
172
|
+
pool.query(
|
|
173
|
+
`SELECT bg.goal_id FROM autonomy_builder_goals bg
|
|
174
|
+
JOIN autonomy_allowed_goals ag ON ag.goal_id = bg.goal_id
|
|
175
|
+
WHERE bg.builder_id = $1 ORDER BY bg.goal_id`,
|
|
176
|
+
[builderId]
|
|
177
|
+
),
|
|
178
|
+
]);
|
|
179
|
+
const row = scope.rows[0];
|
|
180
|
+
if (!row && !goals.rows.length) return null;
|
|
181
|
+
return {
|
|
182
|
+
goals: goals.rows.map((g) => Number(g.goal_id)),
|
|
183
|
+
owner_paused: !!(row && row.owner_paused === true),
|
|
184
|
+
owner_paused_reason: (row && row.owner_paused_reason) || null,
|
|
185
|
+
owner_paused_by: row && row.owner_paused_by ? String(row.owner_paused_by) : null,
|
|
186
|
+
updated_at: row ? row.updated_at : null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// setBuilderGoals — replace one builder's picks with `goalIds`, in one transaction.
|
|
191
|
+
// Every id must already be on the allowlist; if any is not, NOTHING is written and
|
|
192
|
+
// `{ notAllowed: [...] }` says which, so the Settings page can name them. An empty
|
|
193
|
+
// list is a valid answer and means "my runner takes no work".
|
|
194
|
+
async function setBuilderGoals({ builderId, goalIds }) {
|
|
195
|
+
const ids = [...new Set((goalIds || []).map(Number))];
|
|
196
|
+
const client = await pool.connect();
|
|
197
|
+
try {
|
|
198
|
+
await client.query('BEGIN');
|
|
199
|
+
if (ids.length) {
|
|
200
|
+
const { rows } = await client.query('SELECT goal_id FROM autonomy_allowed_goals WHERE goal_id = ANY($1::bigint[])', [ids]);
|
|
201
|
+
const allowed = new Set(rows.map((r) => Number(r.goal_id)));
|
|
202
|
+
const notAllowed = ids.filter((g) => !allowed.has(g));
|
|
203
|
+
if (notAllowed.length) { await client.query('ROLLBACK'); return { notAllowed }; }
|
|
204
|
+
}
|
|
205
|
+
await client.query('DELETE FROM autonomy_builder_goals WHERE builder_id = $1', [builderId]);
|
|
206
|
+
if (ids.length) {
|
|
207
|
+
await client.query(
|
|
208
|
+
'INSERT INTO autonomy_builder_goals (builder_id, goal_id) SELECT $1, unnest($2::bigint[])',
|
|
209
|
+
[builderId, ids]
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// Bump the scope row so the change reaches a waiting runner as a signal.
|
|
213
|
+
await client.query(
|
|
214
|
+
`INSERT INTO autonomy_builder_scope (builder_id, updated_at) VALUES ($1, now())
|
|
215
|
+
ON CONFLICT (builder_id) DO UPDATE SET updated_at = now()`,
|
|
216
|
+
[builderId]
|
|
217
|
+
);
|
|
218
|
+
await client.query('COMMIT');
|
|
219
|
+
return { goals: ids.slice().sort((a, b) => a - b) };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
try { await client.query('ROLLBACK'); } catch (_) { /* the original error is the one to report */ }
|
|
222
|
+
throw err;
|
|
223
|
+
} finally {
|
|
224
|
+
client.release();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// setBuilderPause — the owner pauses or resumes ONE builder's runner. The reason
|
|
229
|
+
// is kept only while paused, like the project switch. Returns null when no such
|
|
230
|
+
// builder exists, so a typo in the id is a 404 rather than an inert orphan row.
|
|
231
|
+
async function setBuilderPause({ builderId, paused, reason = null, byBuilderId = null }) {
|
|
232
|
+
const { rows } = await pool.query(
|
|
233
|
+
`INSERT INTO autonomy_builder_scope (builder_id, owner_paused, owner_paused_reason, owner_paused_by, updated_at)
|
|
234
|
+
SELECT $1, $2, $3, $4, now() WHERE EXISTS (SELECT 1 FROM builders WHERE id = $1)
|
|
235
|
+
ON CONFLICT (builder_id) DO UPDATE SET
|
|
236
|
+
owner_paused = EXCLUDED.owner_paused,
|
|
237
|
+
owner_paused_reason = EXCLUDED.owner_paused_reason,
|
|
238
|
+
owner_paused_by = EXCLUDED.owner_paused_by,
|
|
239
|
+
updated_at = now()
|
|
240
|
+
RETURNING builder_id, owner_paused, owner_paused_reason, updated_at`,
|
|
241
|
+
[builderId, paused === true, paused === true ? (reason || 'paused by the owner') : null, byBuilderId]
|
|
242
|
+
);
|
|
243
|
+
const r = rows[0];
|
|
244
|
+
return r ? { ...r, builder_id: String(r.builder_id) } : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// readAllRunnerScopes — the owner's view: every builder who has a runner scope or
|
|
248
|
+
// has ever had a runner check in, with their picks, pause, and runners. Three
|
|
249
|
+
// small reads joined here rather than one wide SQL join, so a builder with picks
|
|
250
|
+
// but no runner and one with a runner but no picks both appear.
|
|
251
|
+
async function readAllRunnerScopes() {
|
|
252
|
+
const [scopes, picks, beats] = await Promise.all([
|
|
253
|
+
pool.query('SELECT builder_id, owner_paused, owner_paused_reason, updated_at FROM autonomy_builder_scope'),
|
|
254
|
+
pool.query(
|
|
255
|
+
`SELECT bg.builder_id, bg.goal_id FROM autonomy_builder_goals bg
|
|
256
|
+
JOIN autonomy_allowed_goals ag ON ag.goal_id = bg.goal_id ORDER BY bg.goal_id`
|
|
257
|
+
),
|
|
258
|
+
// Capped PER BUILDER, at readRunnerHeartbeats' own 20, so one builder's many
|
|
259
|
+
// hosts can never push a quieter builder out of the owner's view.
|
|
260
|
+
pool.query(
|
|
261
|
+
`SELECT builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
|
|
262
|
+
head, disk_head, main_head, claude_account, last_seen_at
|
|
263
|
+
FROM (SELECT h.*, row_number() OVER (PARTITION BY builder_id ORDER BY last_seen_at DESC) AS rn
|
|
264
|
+
FROM autonomy_runner_heartbeat h) t
|
|
265
|
+
WHERE rn <= 20
|
|
266
|
+
ORDER BY last_seen_at DESC`
|
|
267
|
+
),
|
|
268
|
+
]);
|
|
269
|
+
const byId = new Map();
|
|
270
|
+
const entry = (id) => {
|
|
271
|
+
const k = String(id);
|
|
272
|
+
if (!byId.has(k)) byId.set(k, { builder_id: k, goals: [], owner_paused: false, owner_paused_reason: null, runners: [] });
|
|
273
|
+
return byId.get(k);
|
|
274
|
+
};
|
|
275
|
+
for (const s of scopes.rows) Object.assign(entry(s.builder_id), { owner_paused: s.owner_paused === true, owner_paused_reason: s.owner_paused_reason || null });
|
|
276
|
+
for (const p of picks.rows) entry(p.builder_id).goals.push(Number(p.goal_id));
|
|
277
|
+
for (const b of beats.rows) {
|
|
278
|
+
entry(b.builder_id).runners.push({
|
|
279
|
+
...b, builder_id: undefined,
|
|
280
|
+
working_task_id: b.working_task_id ? String(b.working_task_id) : null,
|
|
281
|
+
working_goal_id: b.working_goal_id ? String(b.working_goal_id) : null,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const ids = [...byId.keys()];
|
|
285
|
+
if (ids.length) {
|
|
286
|
+
const { rows } = await pool.query('SELECT id, github_login, display_name, rank FROM builders WHERE id = ANY($1::bigint[])', [ids]);
|
|
287
|
+
for (const r of rows) Object.assign(byId.get(String(r.id)), { github_login: r.github_login, display_name: r.display_name, rank: r.rank });
|
|
288
|
+
}
|
|
289
|
+
return [...byId.values()];
|
|
290
|
+
}
|
|
291
|
+
|
|
160
292
|
// ── the runner heartbeat (task 1003905) ──────────────────────────────────────
|
|
161
293
|
|
|
162
294
|
// recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
|
|
@@ -225,4 +357,8 @@ module.exports = {
|
|
|
225
357
|
allowGoal,
|
|
226
358
|
disallowGoal,
|
|
227
359
|
setPriorityGoal,
|
|
360
|
+
readBuilderScope,
|
|
361
|
+
setBuilderGoals,
|
|
362
|
+
setBuilderPause,
|
|
363
|
+
readAllRunnerScopes,
|
|
228
364
|
};
|
|
@@ -49,8 +49,78 @@ const REFUSALS = {
|
|
|
49
49
|
goal_not_allowlisted: 'that goal is not on the allowlist',
|
|
50
50
|
protected_path: 'the task declares work on a permission, migration, infrastructure or deploy-pipeline surface',
|
|
51
51
|
grader_unavailable: 'the grader is bypassed, and it is the only review between an unattended worker and main',
|
|
52
|
+
not_permitted: 'your rank does not hold autonomy.run, so your runner may not work on this project',
|
|
53
|
+
builder_paused: 'the owner paused your runner',
|
|
54
|
+
no_builder_scope: 'you have not picked any goals for your runner — choose them in Settings',
|
|
52
55
|
};
|
|
53
56
|
|
|
57
|
+
// The stop codes effectiveFence may set, and the refusal each one becomes.
|
|
58
|
+
const STOP_CODES = new Set(['kill_switch', 'not_permitted', 'builder_paused']);
|
|
59
|
+
|
|
60
|
+
// effectiveFence — the fence ONE builder's runner reads (task 1004501).
|
|
61
|
+
//
|
|
62
|
+
// The project fence is one switch and one allowlist; each builder narrows it to the
|
|
63
|
+
// goals they picked, and the owner can pause one builder without throwing the
|
|
64
|
+
// master switch. This composes the three on the SERVER, so the runner stays dumb:
|
|
65
|
+
// it reads `enabled` and `goals` exactly as before and cannot compute its own
|
|
66
|
+
// widening, and a runner that predates this change gets the narrowing for free.
|
|
67
|
+
//
|
|
68
|
+
// The order is the order an operator asks "why is it idle": the project switch
|
|
69
|
+
// first (it stops everyone), then whether this builder may run at all, then the
|
|
70
|
+
// owner's pause on this builder. Each one wins over the ones after it.
|
|
71
|
+
//
|
|
72
|
+
// Empty picks mean NO goals, by owner ruling (2026-10-01): a builder who has set
|
|
73
|
+
// nothing gets no autonomous work. Never "the whole allowlist".
|
|
74
|
+
//
|
|
75
|
+
// `project` is db.readFence()'s shape; `scope` is db.readBuilderScope()'s
|
|
76
|
+
// ({ goals:[ids], owner_paused, owner_paused_reason, updated_at }) or null for a
|
|
77
|
+
// builder with no row; `mayRun` is the autonomy.run answer, where anything but
|
|
78
|
+
// `true` refuses. Returns null when the project fence is null (unreadable).
|
|
79
|
+
function effectiveFence({ project, scope = null, mayRun = false } = {}) {
|
|
80
|
+
if (!project || typeof project !== 'object') return null;
|
|
81
|
+
const picks = new Set(((scope && scope.goals) || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0));
|
|
82
|
+
const projectGoals = Array.isArray(project.goals) ? project.goals : [];
|
|
83
|
+
const goals = projectGoals.filter((g) => picks.has(Number(g && g.goal_id)));
|
|
84
|
+
const ids = goals.map((g) => Number(g.goal_id));
|
|
85
|
+
const priority = project.priority_goal_id != null && ids.includes(Number(project.priority_goal_id))
|
|
86
|
+
? Number(project.priority_goal_id) : null;
|
|
87
|
+
|
|
88
|
+
let enabled = project.enabled === true;
|
|
89
|
+
let stopCode = enabled ? null : 'kill_switch';
|
|
90
|
+
let pausedReason = enabled ? null : (project.paused_reason || null);
|
|
91
|
+
if (enabled && mayRun !== true) {
|
|
92
|
+
enabled = false; stopCode = 'not_permitted'; pausedReason = REFUSALS.not_permitted;
|
|
93
|
+
} else if (enabled && scope && scope.owner_paused === true) {
|
|
94
|
+
enabled = false; stopCode = 'builder_paused';
|
|
95
|
+
pausedReason = scope.owner_paused_reason ? `${REFUSALS.builder_paused}: ${scope.owner_paused_reason}` : REFUSALS.builder_paused;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The later of the two rows, so a pick or a pause wakes a runner that waits on
|
|
99
|
+
// the fence's updated_at exactly as a project change does.
|
|
100
|
+
const stamps = [project.updated_at, scope && scope.updated_at].filter(Boolean).map((t) => new Date(t).getTime()).filter(Number.isFinite);
|
|
101
|
+
const updatedAt = stamps.length ? new Date(Math.max(...stamps)).toISOString() : (project.updated_at || null);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
enabled,
|
|
105
|
+
paused_reason: pausedReason,
|
|
106
|
+
stop_code: stopCode,
|
|
107
|
+
priority_goal_id: priority,
|
|
108
|
+
updated_at: updatedAt,
|
|
109
|
+
updated_by: project.updated_by ?? null,
|
|
110
|
+
goals,
|
|
111
|
+
// What the caller chose and may do, so the Settings panel and the runner log
|
|
112
|
+
// can say WHY the goals are what they are without a second read.
|
|
113
|
+
scope: {
|
|
114
|
+
may_run: mayRun === true,
|
|
115
|
+
goals: [...picks],
|
|
116
|
+
owner_paused: !!(scope && scope.owner_paused === true),
|
|
117
|
+
owner_paused_reason: (scope && scope.owner_paused_reason) || null,
|
|
118
|
+
},
|
|
119
|
+
// The unnarrowed project fence, for the owner's gate page.
|
|
120
|
+
project,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
54
124
|
// normaliseFence — turn whatever the route returned into something decidable, or
|
|
55
125
|
// null for "unreadable". Deliberately strict: a shape this does not recognise is
|
|
56
126
|
// unreadable, never a permissive default.
|
|
@@ -81,9 +151,18 @@ function normaliseFence(raw) {
|
|
|
81
151
|
// the old behaviour, not a refusal: the allowlist alone is still a safe answer.
|
|
82
152
|
const p = Number(raw.priority_goal_id);
|
|
83
153
|
const priorityGoal = raw.priority_goal_id != null && Number.isSafeInteger(p) && p > 0 && ids.includes(p) ? p : null;
|
|
154
|
+
// Which layer turned it off (task 1004501). Only the known codes are honoured;
|
|
155
|
+
// anything else, including a server that predates them, reads as the kill switch,
|
|
156
|
+
// which refuses just the same.
|
|
157
|
+
const stopCode = STOP_CODES.has(raw.stop_code) ? raw.stop_code : 'kill_switch';
|
|
158
|
+
// A builder who has picked nothing gets nothing (owner ruling, 2026-10-01). Said
|
|
159
|
+
// as its own refusal so the log does not blame an empty project allowlist.
|
|
160
|
+
const noScope = !!(raw.scope && Array.isArray(raw.scope.goals) && raw.scope.goals.length === 0);
|
|
84
161
|
return {
|
|
85
162
|
enabled: raw.enabled === true,
|
|
86
163
|
pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
|
|
164
|
+
stopCode,
|
|
165
|
+
noScope,
|
|
87
166
|
goals: ids,
|
|
88
167
|
priorityGoal,
|
|
89
168
|
};
|
|
@@ -129,6 +208,11 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
|
|
|
129
208
|
if (!fence) return { go: false, code: 'fence_unreadable', reason: REFUSALS.fence_unreadable, goals: [] };
|
|
130
209
|
|
|
131
210
|
if (!fence.enabled) {
|
|
211
|
+
// The builder-level stops carry their full sentence already (effectiveFence
|
|
212
|
+
// wrote it); only the project switch is prefixed, as before.
|
|
213
|
+
if (fence.stopCode !== 'kill_switch') {
|
|
214
|
+
return { go: false, code: fence.stopCode, goals: [], reason: fence.pausedReason || REFUSALS[fence.stopCode] };
|
|
215
|
+
}
|
|
132
216
|
return {
|
|
133
217
|
go: false, code: 'kill_switch', goals: [],
|
|
134
218
|
reason: fence.pausedReason ? `${REFUSALS.kill_switch}: ${fence.pausedReason}` : REFUSALS.kill_switch,
|
|
@@ -148,12 +232,13 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
|
|
|
148
232
|
|
|
149
233
|
const { goals, refused } = allowedGoals(fence, requestedGoals);
|
|
150
234
|
if (!goals.length) {
|
|
235
|
+
const code = refused.length ? 'goal_not_allowlisted' : (fence.noScope ? 'no_builder_scope' : 'no_allowlist');
|
|
151
236
|
return {
|
|
152
237
|
go: false, goals: [], refusedGoals: refused,
|
|
153
|
-
code
|
|
238
|
+
code,
|
|
154
239
|
reason: refused.length
|
|
155
240
|
? `${REFUSALS.goal_not_allowlisted} (asked for ${refused.join(', ')})`
|
|
156
|
-
: REFUSALS
|
|
241
|
+
: REFUSALS[code],
|
|
157
242
|
};
|
|
158
243
|
}
|
|
159
244
|
// priorityGoal is reported only when it survived the intersection, so a row can
|
|
@@ -191,4 +276,4 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
|
|
|
191
276
|
return { go: true, code: null, reason: null };
|
|
192
277
|
}
|
|
193
278
|
|
|
194
|
-
module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, decideRun, decideTask };
|
|
279
|
+
module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, effectiveFence, decideRun, decideTask };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- autonomy_005_builder_scope.sql — each builder's own runner scope, and the
|
|
2
|
+
-- owner's per-builder pause (task 1004501).
|
|
3
|
+
--
|
|
4
|
+
-- Owner ruling, 2026-10-01: every builder says which work THEIR runner takes, and
|
|
5
|
+
-- a builder who has said nothing gets NO autonomous work. Until now the fence was
|
|
6
|
+
-- one switch and one list for every runner on the instance, so the owner could not
|
|
7
|
+
-- stop one builder's machine without stopping all of them, and a builder could not
|
|
8
|
+
-- narrow theirs to the goals they care about.
|
|
9
|
+
--
|
|
10
|
+
-- A NARROWING, NEVER A WIDENING. A builder's picks are a subset of the project
|
|
11
|
+
-- allowlist, and the database holds that rather than a route remembering it:
|
|
12
|
+
-- goal_id references autonomy_allowed_goals, so a goal that is not allowlisted
|
|
13
|
+
-- cannot be picked at all. ON DELETE CASCADE is the safe direction: revoking a
|
|
14
|
+
-- goal from the project removes it from every builder's picks, and re-allowing it
|
|
15
|
+
-- later does NOT silently put it back. The runner's effective goals are
|
|
16
|
+
-- project allowlist ∩ the caller's picks, computed on the server.
|
|
17
|
+
--
|
|
18
|
+
-- ON THE SERVER, for autonomy_001's reason. These rows bound what a machine nobody
|
|
19
|
+
-- is watching may do; a pick list in a file on that machine would be writable by
|
|
20
|
+
-- the very worker it bounds. The builder writes their own picks through an
|
|
21
|
+
-- authenticated route keyed by their session, never by a body field.
|
|
22
|
+
--
|
|
23
|
+
-- autonomy_builder_scope holds the one row per builder that is not a list:
|
|
24
|
+
-- owner_paused the owner stopped THIS builder's runner. Written only by
|
|
25
|
+
-- the autonomy.fence.manage route; the builder cannot clear
|
|
26
|
+
-- it, because a pause the paused party can lift is not one.
|
|
27
|
+
-- owner_paused_reason why, shown to the builder and printed by their runner.
|
|
28
|
+
-- owner_paused_by who.
|
|
29
|
+
-- updated_at bumped on every pick or pause change. The route folds it
|
|
30
|
+
-- into the fence's updated_at, which is how a change wakes
|
|
31
|
+
-- a runner that is waiting for a signal.
|
|
32
|
+
--
|
|
33
|
+
-- No foreign key to builders, like autonomy_002: a row for a removed builder is
|
|
34
|
+
-- inert, because no session can ever read it.
|
|
35
|
+
--
|
|
36
|
+
-- Additive and idempotent (ADR 0083 §Decision #5): safe to re-run.
|
|
37
|
+
|
|
38
|
+
BEGIN;
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS autonomy_builder_goals (
|
|
41
|
+
builder_id bigint NOT NULL,
|
|
42
|
+
goal_id bigint NOT NULL REFERENCES autonomy_allowed_goals (goal_id) ON DELETE CASCADE,
|
|
43
|
+
added_at timestamptz NOT NULL DEFAULT now(),
|
|
44
|
+
PRIMARY KEY (builder_id, goal_id)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS autonomy_builder_scope (
|
|
48
|
+
builder_id bigint PRIMARY KEY,
|
|
49
|
+
owner_paused boolean NOT NULL DEFAULT false,
|
|
50
|
+
owner_paused_reason text,
|
|
51
|
+
owner_paused_by bigint,
|
|
52
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
COMMIT;
|
|
@@ -28,6 +28,7 @@ const auth = api;
|
|
|
28
28
|
// neither core internals nor a sibling module, so importing it here is allowed.
|
|
29
29
|
const autonomyGate = require('../../../scripts/gds/autonomy-gate');
|
|
30
30
|
const db = require('../db');
|
|
31
|
+
const fence = require('../fence');
|
|
31
32
|
const runnerHealth = require('../runner-health');
|
|
32
33
|
// The doorway's namespaced logger and strict body validator. Raw console.* is
|
|
33
34
|
// ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
|
|
@@ -52,6 +53,49 @@ function runsLimit(raw) {
|
|
|
52
53
|
return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// holdsPermission — does this builder hold `key`? Asked of the government port
|
|
57
|
+
// rather than mounted as a gate, because GET /autonomy/fence must answer EVERY
|
|
58
|
+
// builder (a runner must always be able to learn it should stop); only the answer
|
|
59
|
+
// changes. Fails closed: no port, or a resolver that throws, reads as "not held",
|
|
60
|
+
// which turns the caller's fence off rather than on. The rot.js precedent.
|
|
61
|
+
//
|
|
62
|
+
// THE FOLD FIRST. This runs on the runner's read, every iteration of every runner,
|
|
63
|
+
// so it must not add a query. requireBuilder already carries the session's grant
|
|
64
|
+
// keys (`req.rawPermissionKeys`, the session-grants fold of task 1002556), and the
|
|
65
|
+
// government port folds them into the held set in memory — the same path
|
|
66
|
+
// requirePermission takes. The DB resolve is only the fallback for a session row
|
|
67
|
+
// that did not carry the keys, exactly as in requirePermission.
|
|
68
|
+
async function holdsPermission(req, key) {
|
|
69
|
+
try {
|
|
70
|
+
const governance = api.resolveOptional('government');
|
|
71
|
+
if (!governance) return false;
|
|
72
|
+
if (Array.isArray(req.rawPermissionKeys) && typeof governance.effectivePermissions === 'function') {
|
|
73
|
+
try {
|
|
74
|
+
return new Set(governance.effectivePermissions(req.rawPermissionKeys)).has(key);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.error(`autonomy: permission fold failed for ${key} — falling back to the resolve: ${err && err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (typeof governance.builderHasPermissions !== 'function') return false;
|
|
80
|
+
return (await governance.builderHasPermissions(Number(req.builder.id), [key])) === true;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log.error(`autonomy: permission resolve failed for ${key} — denying fail-closed: ${err && err.message}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// goalIdsOrNull — a list of positive integer goal ids, or null for a malformed one.
|
|
88
|
+
function goalIdsOrNull(v) {
|
|
89
|
+
if (!Array.isArray(v) || v.length > 200) return null;
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const g of v) {
|
|
92
|
+
const n = Number(g);
|
|
93
|
+
if (!Number.isSafeInteger(n) || n <= 0) return null;
|
|
94
|
+
out.push(n);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
// commitOrNull — a full lowercase commit sha, or null (task 1004407).
|
|
56
100
|
function commitOrNull(v) {
|
|
57
101
|
return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
|
|
@@ -102,15 +146,24 @@ module.exports = function buildAutonomyRouter() {
|
|
|
102
146
|
// you must stop should never require an elevated session, because the failure
|
|
103
147
|
// mode of "could not read the fence" is a machine that keeps going. The body
|
|
104
148
|
// carries no secret — it is a boolean, a reason string and a list of goal ids.
|
|
149
|
+
//
|
|
150
|
+
// THE CALLER'S fence, not the project's (task 1004501). The top-level `enabled`
|
|
151
|
+
// and `goals` are what THIS builder's runner may do: the project switch, then
|
|
152
|
+
// `autonomy.run`, then the owner's pause on this builder, and the allowlist
|
|
153
|
+
// narrowed to their own picks (fence.js effectiveFence). Computing it here keeps
|
|
154
|
+
// the runner dumb and means a runner that predates this change is narrowed too.
|
|
155
|
+
// The unnarrowed project fence rides along as `project` for the owner's page.
|
|
105
156
|
router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
|
|
106
157
|
try {
|
|
107
|
-
const
|
|
158
|
+
const [project, scope, mayRun] = await Promise.all([
|
|
159
|
+
db.readFence(), db.readBuilderScope(req.builder.id), holdsPermission(req, 'autonomy.run'),
|
|
160
|
+
]);
|
|
108
161
|
// Null means the singleton row is missing, i.e. the migration has not run.
|
|
109
162
|
// Say so rather than synthesising a default: a fabricated { enabled: false }
|
|
110
163
|
// is indistinguishable from a real one, and an operator would chase a kill
|
|
111
164
|
// switch nobody threw.
|
|
112
|
-
if (!
|
|
113
|
-
res.json(fence);
|
|
165
|
+
if (!project) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
|
|
166
|
+
res.json(fence.effectiveFence({ project, scope, mayRun }));
|
|
114
167
|
} catch (err) {
|
|
115
168
|
log.error('[gds] GET /autonomy/fence', err);
|
|
116
169
|
// FAIL CLOSED, unlike the precheck above. That one is fail-open because an
|
|
@@ -202,6 +255,73 @@ module.exports = function buildAutonomyRouter() {
|
|
|
202
255
|
}
|
|
203
256
|
});
|
|
204
257
|
|
|
258
|
+
// ── each builder's own runner scope (task 1004501) ─────────────────────────
|
|
259
|
+
|
|
260
|
+
// A builder picks which allowlisted goals THEIR runner works. Replaces the whole
|
|
261
|
+
// list; `[]` means "my runner takes no work". Keyed by the session, never the
|
|
262
|
+
// body, so nobody can set another builder's picks. Behind `autonomy.run`: a
|
|
263
|
+
// builder who may not run a runner has nothing to scope. A goal not on the
|
|
264
|
+
// project allowlist is refused with 409 and nothing is written — the pick can
|
|
265
|
+
// narrow the project fence and never widen it.
|
|
266
|
+
router.put('/autonomy/me/goals', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
|
|
267
|
+
if (validateOrRespond(req, res, { goal_ids: { required: true } })) return;
|
|
268
|
+
const goalIds = goalIdsOrNull(req.body && req.body.goal_ids);
|
|
269
|
+
if (!goalIds) return res.fail('goal_ids_required', { status: 400, message: 'pass { "goal_ids": [<number>, …] } — an empty list means no work' });
|
|
270
|
+
try {
|
|
271
|
+
const out = await db.setBuilderGoals({ builderId: req.builder.id, goalIds });
|
|
272
|
+
if (out.notAllowed) return res.fail('goal_not_allowlisted', { status: 409, message: `not on the project allowlist: ${out.notAllowed.join(', ')}` });
|
|
273
|
+
log.info(`[gds] autonomy scope: builder ${req.builder.id} picked goals [${out.goals.join(', ')}]`);
|
|
274
|
+
res.json({ goals: out.goals });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
log.error('[gds] PUT /autonomy/me/goals', err);
|
|
277
|
+
res.fail('scope_write_failed', { status: 500, message: 'internal error' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// The owner pauses or resumes ONE builder's runner without throwing the master
|
|
282
|
+
// switch. Same atom as the kill switch: stopping a machine is the same act
|
|
283
|
+
// whether it is everyone's or one person's, and the paused builder must not be
|
|
284
|
+
// able to lift it (autonomy.run does not reach this route).
|
|
285
|
+
router.post('/autonomy/builders/:builderId/pause', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
|
|
286
|
+
if (validateOrRespond(req, res, {
|
|
287
|
+
paused: { required: true, type: 'boolean' },
|
|
288
|
+
reason: { type: 'string', maxLength: 500 },
|
|
289
|
+
})) return;
|
|
290
|
+
const builderId = Number(req.params.builderId);
|
|
291
|
+
if (!Number.isSafeInteger(builderId) || builderId <= 0) return res.fail('bad_builder_id', 400);
|
|
292
|
+
const reason = typeof req.body.reason === 'string' ? req.body.reason : null;
|
|
293
|
+
try {
|
|
294
|
+
const row = await db.setBuilderPause({ builderId, paused: req.body.paused, reason, byBuilderId: req.builder.id });
|
|
295
|
+
if (!row) return res.fail('builder_not_found', { status: 404, message: `no builder ${builderId}` });
|
|
296
|
+
log.info(`[gds] autonomy: builder ${builderId}'s runner ${req.body.paused ? 'PAUSED' : 'RESUMED'} by builder ${req.builder.id} (${req.builder.rank})${reason ? `: ${reason}` : ''}`);
|
|
297
|
+
res.json(row);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
log.error('[gds] POST /autonomy/builders/:builderId/pause', err);
|
|
300
|
+
res.fail('pause_write_failed', { status: 500, message: 'internal error' });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The owner's view of every builder's runners: who, which goals they picked,
|
|
305
|
+
// whether they are paused, and each runner's health — the server's verdict, as
|
|
306
|
+
// GET /autonomy/runners attaches it. Owner-only: a heartbeat names a machine
|
|
307
|
+
// and its hostname, which is each builder's own business (task 1003905), and the
|
|
308
|
+
// owner is the one person who needs every row to run the fence.
|
|
309
|
+
router.get('/autonomy/runners/all', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
|
|
310
|
+
try {
|
|
311
|
+
const rows = await db.readAllRunnerScopes();
|
|
312
|
+
const nowMs = Date.now();
|
|
313
|
+
res.json({
|
|
314
|
+
builders: rows.map((b) => ({
|
|
315
|
+
...b,
|
|
316
|
+
runners: b.runners.map((r) => ({ ...r, health: runnerHealth.describeRunner(r, { nowMs }) })),
|
|
317
|
+
})),
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log.error('[gds] GET /autonomy/runners/all', err);
|
|
321
|
+
res.fail('runners_unreadable', { status: 503, message: 'could not read the runner scopes' });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
205
325
|
// ── the runner heartbeat (task 1003905) ────────────────────────────────────
|
|
206
326
|
//
|
|
207
327
|
// A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/earnings.js — WHAT EARNED A BUILDER'S CREDITS, DAY BY DAY
|
|
4
|
+
// (task 1004436 / WA6.RP06, goal 1000095, criterion wa6-roles-read-true-in-public).
|
|
5
|
+
//
|
|
6
|
+
// WHY. The profile's earnings chart drew only for someone who had shipped a task
|
|
7
|
+
// (profile-charts.js stopped at "No works shipped yet" before it read a credit),
|
|
8
|
+
// and its line came from the itemised ledger, which another builder may read only
|
|
9
|
+
// with builder.roster.read. So an artist or an ideator with a full credit history
|
|
10
|
+
// got no chart at all, and a signed-in viewer below Metic got an estimate summed
|
|
11
|
+
// from shipped tasks, which for those two crafts is nothing. The owner's decision
|
|
12
|
+
// (a), 2026-09-30, is that earnings are a SIGNED-IN read, for everyone.
|
|
13
|
+
//
|
|
14
|
+
// This is that read: per day, per KIND of credit, summed. No row, no task, no
|
|
15
|
+
// session and no description leaves here, so it carries none of the itemisation
|
|
16
|
+
// the ledger route gates. The total already sits on the public leaderboard; what
|
|
17
|
+
// this adds is when it was earned and by what kind of work.
|
|
18
|
+
//
|
|
19
|
+
// THE KINDS, per credit_log row (first match wins):
|
|
20
|
+
//
|
|
21
|
+
// task.shipped · task.confirmed task Tasks shipped
|
|
22
|
+
// session.token_reward session Session rewards
|
|
23
|
+
// tweak.page_approved:<task> page Pages approved
|
|
24
|
+
// idea.credit.task_ship: (a descent hop) idea-descent Work split from their ideas
|
|
25
|
+
// idea.credit.task_ship: · idea_promotion_ idea-ship Ideas that became shipped work
|
|
26
|
+
// bonus
|
|
27
|
+
// idea.credit.full_pass: idea-full Full Ideas passed
|
|
28
|
+
// idea.credit.governance_pass: idea-board Ratified by the board
|
|
29
|
+
// ship.net_negative net-negative Simplification bonuses
|
|
30
|
+
// anything else other achievements, rank, a grant
|
|
31
|
+
//
|
|
32
|
+
// A descent payment shares stream 1's reason key by design (credits.js: one
|
|
33
|
+
// payment per origin per ship, however the descent is re-walked), so the reason
|
|
34
|
+
// cannot tell it apart. Its description can: the descent writer, and only it,
|
|
35
|
+
// says the task shipped "N hop(s) below idea #". DESCENT_MARK is that phrase, and
|
|
36
|
+
// tests/profile_earnings.mjs holds it against the writer's own text.
|
|
37
|
+
//
|
|
38
|
+
// The words for each kind live with the picture (profile-charts.js); this file
|
|
39
|
+
// owns only which kind a credit is.
|
|
40
|
+
//
|
|
41
|
+
// ONLY READS. Nothing here writes, and nothing is reachable from a ship path.
|
|
42
|
+
|
|
43
|
+
const api = require('../../src/module-api');
|
|
44
|
+
const { pool } = api;
|
|
45
|
+
const { ARTIST_PAGE_REASON_PREFIX, IDEA_STREAM_REASON_PREFIXES } = require('./credits');
|
|
46
|
+
|
|
47
|
+
const KINDS = Object.freeze([
|
|
48
|
+
'task', 'session', 'page', 'idea-ship', 'idea-descent', 'idea-full', 'idea-board', 'net-negative', 'other',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const DESCENT_MARK = ' below idea #';
|
|
52
|
+
|
|
53
|
+
// The prefixes go into the SQL as LIKE patterns, so their underscores (a LIKE
|
|
54
|
+
// wildcard) are escaped. Checked at load: they are economy's own constants,
|
|
55
|
+
// never a request value, and the check keeps it that way.
|
|
56
|
+
function likePrefix(prefix) {
|
|
57
|
+
if (!/^[a-z0-9_.]+:$/i.test(prefix)) {
|
|
58
|
+
throw new Error(`economy/earnings: reason prefix is not a plain literal: ${prefix}`);
|
|
59
|
+
}
|
|
60
|
+
return prefix.replace(/([\\%_])/g, '\\$1') + '%';
|
|
61
|
+
}
|
|
62
|
+
const LIKE = Object.freeze({
|
|
63
|
+
page: likePrefix(ARTIST_PAGE_REASON_PREFIX),
|
|
64
|
+
ship: likePrefix(IDEA_STREAM_REASON_PREFIXES.task_ship),
|
|
65
|
+
full: likePrefix(IDEA_STREAM_REASON_PREFIXES.full_pass),
|
|
66
|
+
board: likePrefix(IDEA_STREAM_REASON_PREFIXES.governance_pass),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// One statement, one builder: idx_credit_log_builder (builder_id, recorded_at)
|
|
70
|
+
// serves the WHERE, so the read is that person's rows, never the whole ledger.
|
|
71
|
+
// The day is the UTC day, the one the page's axis and the ledger fixture use.
|
|
72
|
+
const EARNINGS_SQL = `
|
|
73
|
+
SELECT to_char((recorded_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day,
|
|
74
|
+
CASE
|
|
75
|
+
WHEN reason IN ('task.shipped', 'task.confirmed') THEN 'task'
|
|
76
|
+
WHEN reason = 'session.token_reward' THEN 'session'
|
|
77
|
+
WHEN reason LIKE $2 THEN 'page'
|
|
78
|
+
WHEN reason LIKE $3 AND COALESCE(description, '') LIKE $6 THEN 'idea-descent'
|
|
79
|
+
WHEN reason LIKE $3 OR reason = 'idea_promotion_bonus' THEN 'idea-ship'
|
|
80
|
+
WHEN reason LIKE $4 THEN 'idea-full'
|
|
81
|
+
WHEN reason LIKE $5 THEN 'idea-board'
|
|
82
|
+
WHEN reason = 'ship.net_negative' THEN 'net-negative'
|
|
83
|
+
ELSE 'other'
|
|
84
|
+
END AS kind,
|
|
85
|
+
SUM(delta)::float8 AS credits
|
|
86
|
+
FROM credit_log
|
|
87
|
+
WHERE builder_id = $1
|
|
88
|
+
GROUP BY 1, 2
|
|
89
|
+
ORDER BY 1, 2`;
|
|
90
|
+
|
|
91
|
+
// foldEarnings — the grouped rows into the route's shape. Pure. Days ascending;
|
|
92
|
+
// `totals` names every kind (0 where nothing was earned, so a reader never has
|
|
93
|
+
// to guess whether a missing key means zero); `total` is the sum of the parts,
|
|
94
|
+
// which is the ledger's own sum for this builder.
|
|
95
|
+
function foldEarnings(rows) {
|
|
96
|
+
const totals = {};
|
|
97
|
+
for (const k of KINDS) totals[k] = 0;
|
|
98
|
+
const days = [];
|
|
99
|
+
for (const r of rows || []) {
|
|
100
|
+
const kind = KINDS.includes(r.kind) ? r.kind : 'other';
|
|
101
|
+
const credits = Math.round(Number(r.credits) || 0);
|
|
102
|
+
if (!r.day || credits === 0) continue;
|
|
103
|
+
days.push({ day: String(r.day), kind, credits });
|
|
104
|
+
totals[kind] += credits;
|
|
105
|
+
}
|
|
106
|
+
days.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)));
|
|
107
|
+
const total = Object.values(totals).reduce((a, b) => a + b, 0);
|
|
108
|
+
return { days, totals, total };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readEarnings(builderId, { client = null } = {}) {
|
|
112
|
+
const q = client || pool;
|
|
113
|
+
const { rows } = await q.query(EARNINGS_SQL, [builderId, LIKE.page, LIKE.ship, LIKE.full, LIKE.board, `%${DESCENT_MARK}%`]);
|
|
114
|
+
return foldEarnings(rows);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { readEarnings, foldEarnings, KINDS, DESCENT_MARK, EARNINGS_SQL };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"coreVersion": "^1.7.0",
|
|
7
7
|
"default": true,
|
|
8
8
|
"maintenance": { "status": "core-maintained" },
|
|
9
|
-
"contributes": { "routes": ["cost", "credits", "leaderboard", "reward"] },
|
|
9
|
+
"contributes": { "routes": ["cost", "credits", "earnings", "leaderboard", "reward"] },
|
|
10
10
|
"provides": ["reward"],
|
|
11
11
|
"consumes": []
|
|
12
12
|
}
|