@bongos/core 1.24.9 → 1.24.10
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 +113 -73
- 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 +10 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +178 -2
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +47 -19
- package/docs/copy-registry.json +306 -24
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +34 -4
- package/docs/page-readings.json +296 -239
- package/modules/hall-ui/public/ai-studio.html +2 -2
- package/modules/hall-ui/public/atlas.html +2 -2
- package/modules/hall-ui/public/blockers.html +2 -2
- package/modules/hall-ui/public/board-room.html +2 -2
- package/modules/hall-ui/public/city.html +2 -2
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/commands.html +2 -2
- package/modules/hall-ui/public/copy-desk.html +2 -2
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +2 -2
- package/modules/hall-ui/public/diagrams.html +2 -2
- package/modules/hall-ui/public/drachmae.html +2 -2
- package/modules/hall-ui/public/fleet.html +2 -2
- package/modules/hall-ui/public/gate.html +2 -2
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +2 -2
- package/modules/hall-ui/public/government.html +2 -2
- package/modules/hall-ui/public/idea.html +2 -2
- package/modules/hall-ui/public/ideas.html +2 -2
- package/modules/hall-ui/public/index.html +2 -2
- package/modules/hall-ui/public/market-watch-rules.html +2 -2
- package/modules/hall-ui/public/market-watch-settings.css +217 -0
- package/modules/hall-ui/public/market-watch-settings.html +113 -0
- package/modules/hall-ui/public/market-watch-settings.js +230 -0
- package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
- package/modules/hall-ui/public/modules.html +2 -2
- package/modules/hall-ui/public/my-agents.html +2 -2
- package/modules/hall-ui/public/primer.html +2 -2
- package/modules/hall-ui/public/profile.html +2 -2
- package/modules/hall-ui/public/project-settings.html +2 -2
- package/modules/hall-ui/public/ranks.html +2 -2
- package/modules/hall-ui/public/roadmap.html +2 -2
- package/modules/hall-ui/public/roster.html +2 -2
- package/modules/hall-ui/public/sessions.html +2 -2
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/street.html +2 -2
- package/modules/hall-ui/public/studio.html +2 -2
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +2 -2
- package/modules/hall-ui/public/watch.html +2 -2
- package/modules/hall-ui/public/work.html +2 -2
- package/modules/hall-ui/records/market-watch-settings.md +11 -0
- package/modules/market-watch/module.json +3 -2
- package/modules/market-watch/routes/settings.js +128 -0
- package/modules/market-watch/settings.js +207 -0
- package/modules/provisioning/starter-bundles.js +11 -6
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/hall_nav.mjs +4 -2
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_data_db.mjs +27 -0
- package/tests/market_watch_module.mjs +8 -4
- package/tests/market_watch_settings.mjs +392 -0
- package/tests/nav_permission_atoms.mjs +3 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// modules/market-watch/routes/settings.js — the owner's Market Watch settings (task 1004938,
|
|
2
|
+
// goal 1000137). The page is the hall's /market-watch-settings, built to
|
|
3
|
+
// docs/design/mocks/market-watch/settings.html (option B, the fuel gauge).
|
|
4
|
+
//
|
|
5
|
+
// GET /market-watch/settings the switch, cap, cadence, this month's spend, the next
|
|
6
|
+
// run, recent runs, and whether a key is set
|
|
7
|
+
// PATCH /market-watch/settings change any of { enabled, monthly_cap_usd, cadence }
|
|
8
|
+
// PUT /market-watch/settings/key save or replace the project's Anthropic key { key }
|
|
9
|
+
// DELETE /market-watch/settings/key clear it
|
|
10
|
+
//
|
|
11
|
+
// rank: archon — the owner seat. Two server-side gates on every route, the read included
|
|
12
|
+
// (ADR 0016; the page hiding a control is never the wall):
|
|
13
|
+
// 1. requirePermission('project.settings.manage') — the catalog atom for "the project's
|
|
14
|
+
// own live knobs" (modules/government/catalog.js, floor metic). Module routes gate on
|
|
15
|
+
// atoms, never on a rank gate (tests/government_require_permission.mjs).
|
|
16
|
+
// 2. ownerSeat — the owner seat is every active Archon (government leads.ownerOf), and
|
|
17
|
+
// the task says owner-only, so a Metic who holds the atom is still refused 403
|
|
18
|
+
// rank_forbidden. No archon-floor atom names this authority, and borrowing an
|
|
19
|
+
// unrelated one would name the wrong authority (the scouting holdout's reasoning in
|
|
20
|
+
// that test). The security_sensitive check in lifecycle's task-write-routes is the
|
|
21
|
+
// precedent for an archon check behind a wider atom.
|
|
22
|
+
// The read is owner-only too: it carries the project's AI spend and whether its key is set.
|
|
23
|
+
//
|
|
24
|
+
// The key is write-only. No route returns it or any part of it; the body field is named
|
|
25
|
+
// `key`, which the audit middleware redacts, and no log line here carries the body.
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const express = require('express');
|
|
30
|
+
const api = require('../../../src/module-api');
|
|
31
|
+
const settings = require('../settings');
|
|
32
|
+
const { projectKey } = require('../seed');
|
|
33
|
+
|
|
34
|
+
const log = api.logger('market-watch');
|
|
35
|
+
|
|
36
|
+
const MAX_KEY_BODY = 512;
|
|
37
|
+
const KEY_MESSAGES = {
|
|
38
|
+
key_required: 'Paste the key.',
|
|
39
|
+
key_wrong_length: 'That does not look like an Anthropic key. Paste the whole key.',
|
|
40
|
+
key_has_spaces: 'That does not look like an Anthropic key. Paste it with no spaces.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The project is this instance's own public origin, as the run and the seed read it.
|
|
44
|
+
function currentProject() {
|
|
45
|
+
try { return projectKey(api.branding().domains.publicOrigin); } catch (_) { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ceilingUsd() {
|
|
49
|
+
try { return require('../research').itemCeilingUsd(); } catch (_) { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function storageConfigured() {
|
|
53
|
+
try { return api.secretBox.isConfigured() === true; } catch (_) { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = function buildMarketWatchSettingsRouter(deps = {}) {
|
|
57
|
+
const router = express.Router();
|
|
58
|
+
// Seams for the tests: the pool, the project and the box. Production passes nothing.
|
|
59
|
+
const pool = () => deps.pool || api.pool;
|
|
60
|
+
const project = () => (deps.project !== undefined ? deps.project : currentProject());
|
|
61
|
+
const box = () => deps.secretBox || api.secretBox;
|
|
62
|
+
const isStorageConfigured = () => (deps.secretBox ? deps.secretBox.isConfigured() === true : storageConfigured());
|
|
63
|
+
|
|
64
|
+
// The owner seat, checked after the atom: holding project.settings.manage is not enough.
|
|
65
|
+
function ownerSeat(req, res, next) {
|
|
66
|
+
const actual = req.builder && req.builder.rank;
|
|
67
|
+
if (!api.rankMeetsThreshold(actual, ['archon'])) {
|
|
68
|
+
return res.fail('rank_forbidden', 403, { required: ['archon'], actual: actual || null, reason: 'Only the project owner can see or change Market Watch settings.' });
|
|
69
|
+
}
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withProject(res) {
|
|
74
|
+
const p = project();
|
|
75
|
+
if (!p) res.fail('project_origin_unset', { status: 409, message: 'This project has no public address set, so Market Watch cannot tell which project it is.' });
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function view(p) {
|
|
80
|
+
return settings.readView(pool(), p, {
|
|
81
|
+
now: deps.now ? new Date(deps.now()) : new Date(),
|
|
82
|
+
ceilingUsd: deps.ceilingUsd !== undefined ? deps.ceilingUsd : ceilingUsd(),
|
|
83
|
+
storageConfigured: isStorageConfigured(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
router.get('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('GET /market-watch/settings', async (req, res) => {
|
|
88
|
+
const p = withProject(res);
|
|
89
|
+
if (!p) return;
|
|
90
|
+
res.json(await view(p));
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
router.patch('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PATCH /market-watch/settings', async (req, res) => {
|
|
94
|
+
// Strict field list (unknown fields refused by the envelope); the values are checked by
|
|
95
|
+
// settings.parsePatch, the one validator the tests also run.
|
|
96
|
+
if (api.validateOrRespond(req, res, { enabled: {}, monthly_cap_usd: {}, cadence: {} })) return;
|
|
97
|
+
const parsed = settings.parsePatch(req.body);
|
|
98
|
+
if (parsed.error) return res.fail(parsed.error, { status: 400, message: parsed.message });
|
|
99
|
+
const p = withProject(res);
|
|
100
|
+
if (!p) return;
|
|
101
|
+
const saved = await settings.writeSettings(pool(), p, parsed.patch, req.builder.id);
|
|
102
|
+
log.info({ builder_id: req.builder.id, fields: Object.keys(parsed.patch), enabled: saved.enabled }, 'market watch settings changed');
|
|
103
|
+
res.json(await view(p));
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
router.put('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PUT /market-watch/settings/key', async (req, res) => {
|
|
107
|
+
if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: MAX_KEY_BODY } })) return;
|
|
108
|
+
const p = withProject(res);
|
|
109
|
+
if (!p) return;
|
|
110
|
+
if (!isStorageConfigured()) {
|
|
111
|
+
return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
|
|
112
|
+
}
|
|
113
|
+
const out = await settings.setKey(pool(), p, req.body.key, req.builder.id, { encrypt: (s) => box().encrypt(s) });
|
|
114
|
+
if (out.error) return res.fail(out.error, { status: 400, message: KEY_MESSAGES[out.error] || 'That key could not be saved.' });
|
|
115
|
+
log.info({ builder_id: req.builder.id }, 'market watch key saved');
|
|
116
|
+
res.json(await view(p));
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
router.delete('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('DELETE /market-watch/settings/key', async (req, res) => {
|
|
120
|
+
const p = withProject(res);
|
|
121
|
+
if (!p) return;
|
|
122
|
+
const out = await settings.clearKey(pool(), p, req.builder.id);
|
|
123
|
+
log.info({ builder_id: req.builder.id, removed: out.removed }, 'market watch key cleared');
|
|
124
|
+
res.json(await view(p));
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
return router;
|
|
128
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/settings.js — the owner's Market Watch settings (task 1004938, goal
|
|
4
|
+
// 1000137). The design of record is docs/design/mocks/market-watch/settings.html, option B,
|
|
5
|
+
// the fuel gauge.
|
|
6
|
+
//
|
|
7
|
+
// What the owner sets here, and what reads it back:
|
|
8
|
+
//
|
|
9
|
+
// enabled the watch switch. The weekly run's first pre-check (run.js precheck)
|
|
10
|
+
// reads it through store.readSettings: off means the next due tick
|
|
11
|
+
// records a skipped run with reason 'off' and spends nothing.
|
|
12
|
+
// monthly_cap_usd the monthly AI cap. The run checks it before the run and before every
|
|
13
|
+
// item (run.js capAllows).
|
|
14
|
+
// cadence weekly | biweekly | monthly (run.js isDue, db.CADENCE_DAYS).
|
|
15
|
+
// the key the project's own Anthropic key, sealed by key.js sealProjectKey into
|
|
16
|
+
// the columns key.js readProjectKey opens (market_watch_002_run.sql).
|
|
17
|
+
//
|
|
18
|
+
// THE KEY IS WRITE-ONLY. It can be replaced or cleared, never read back: the view answers
|
|
19
|
+
// key_set true or false and nothing else, not the last four characters the table keeps
|
|
20
|
+
// for its CHECK constraint, not the date. Nothing in this file decrypts.
|
|
21
|
+
//
|
|
22
|
+
// Every statement here filters on the project key, like db.js and store.js. Validation is
|
|
23
|
+
// pure (parsePatch) so the route and the tests share it.
|
|
24
|
+
|
|
25
|
+
const db = require('./db');
|
|
26
|
+
const { sealProjectKey } = require('./key');
|
|
27
|
+
|
|
28
|
+
const CADENCES = Object.freeze(Object.keys(db.CADENCE_DAYS));
|
|
29
|
+
// The cap's bounds, in dollars. The page's slider walks 5 to 60 in steps of 5 (the
|
|
30
|
+
// mockup); the server accepts any whole-cent amount in this range so a cap set by the API
|
|
31
|
+
// is not refused for being off the slider's grid.
|
|
32
|
+
const MIN_CAP_USD = 1;
|
|
33
|
+
const MAX_CAP_USD = 1000;
|
|
34
|
+
const PATCH_FIELDS = Object.freeze(['enabled', 'monthly_cap_usd', 'cadence']);
|
|
35
|
+
// How many recent runs the strip shows, and how many of them the run estimate averages.
|
|
36
|
+
const RECENT_RUNS = 6;
|
|
37
|
+
const ESTIMATE_FROM = 3;
|
|
38
|
+
|
|
39
|
+
// parsePatch(body) → { patch } | { error, message }. At least one known field; no others.
|
|
40
|
+
function parsePatch(body) {
|
|
41
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'bad_settings', message: 'Send the settings to change.' };
|
|
42
|
+
const unknown = Object.keys(body).filter((k) => !PATCH_FIELDS.includes(k));
|
|
43
|
+
if (unknown.length) return { error: 'bad_settings', message: `Unknown setting: ${unknown.join(', ')}.` };
|
|
44
|
+
const patch = {};
|
|
45
|
+
if (body.enabled !== undefined) {
|
|
46
|
+
if (typeof body.enabled !== 'boolean') return { error: 'bad_settings', message: 'enabled is true or false.' };
|
|
47
|
+
patch.enabled = body.enabled;
|
|
48
|
+
}
|
|
49
|
+
if (body.monthly_cap_usd !== undefined) {
|
|
50
|
+
const n = body.monthly_cap_usd;
|
|
51
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < MIN_CAP_USD || n > MAX_CAP_USD) {
|
|
52
|
+
return { error: 'bad_cap', message: `The monthly cap is between $${MIN_CAP_USD} and $${MAX_CAP_USD}.` };
|
|
53
|
+
}
|
|
54
|
+
patch.monthly_cap_usd = Math.round(n * 100) / 100;
|
|
55
|
+
}
|
|
56
|
+
if (body.cadence !== undefined) {
|
|
57
|
+
if (!CADENCES.includes(body.cadence)) return { error: 'bad_cadence', message: `cadence is one of ${CADENCES.join(', ')}.` };
|
|
58
|
+
patch.cadence = body.cadence;
|
|
59
|
+
}
|
|
60
|
+
if (!Object.keys(patch).length) return { error: 'bad_settings', message: 'Send at least one setting to change.' };
|
|
61
|
+
return { patch };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One row per project, created on the first write with the defaults every unconfigured
|
|
65
|
+
// project already reads (db.DEFAULT_SETTINGS), so a first write of one field never
|
|
66
|
+
// invents the others.
|
|
67
|
+
const UPSERT_SQL = `
|
|
68
|
+
INSERT INTO market_watch_settings (project, enabled, monthly_cap_usd, cadence, updated_by, updated_at)
|
|
69
|
+
VALUES ($1, COALESCE($2::boolean, $6::boolean), COALESCE($3::numeric, $7::numeric), COALESCE($4::text, $8::text), $5, now())
|
|
70
|
+
ON CONFLICT (project) DO UPDATE SET
|
|
71
|
+
enabled = COALESCE($2::boolean, market_watch_settings.enabled),
|
|
72
|
+
monthly_cap_usd = COALESCE($3::numeric, market_watch_settings.monthly_cap_usd),
|
|
73
|
+
cadence = COALESCE($4::text, market_watch_settings.cadence),
|
|
74
|
+
updated_by = $5,
|
|
75
|
+
updated_at = now()
|
|
76
|
+
RETURNING enabled, monthly_cap_usd, cadence`;
|
|
77
|
+
|
|
78
|
+
async function writeSettings(pool, project, patch, builderId) {
|
|
79
|
+
const d = db.DEFAULT_SETTINGS;
|
|
80
|
+
const v = (k) => (patch[k] === undefined ? null : patch[k]);
|
|
81
|
+
const { rows: [r] } = await pool.query(UPSERT_SQL, [
|
|
82
|
+
need(project), v('enabled'), v('monthly_cap_usd'), v('cadence'), builderId,
|
|
83
|
+
d.enabled, d.monthly_cap_usd, d.cadence,
|
|
84
|
+
]);
|
|
85
|
+
return { enabled: r.enabled === true, monthly_cap_usd: Number(r.monthly_cap_usd), cadence: r.cadence };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SET_KEY_SQL = `
|
|
89
|
+
INSERT INTO market_watch_settings (project, anthropic_key_enc, anthropic_key_last4, anthropic_key_set_at, updated_by, updated_at)
|
|
90
|
+
VALUES ($1, $2, $3, now(), $4, now())
|
|
91
|
+
ON CONFLICT (project) DO UPDATE SET
|
|
92
|
+
anthropic_key_enc = EXCLUDED.anthropic_key_enc,
|
|
93
|
+
anthropic_key_last4 = EXCLUDED.anthropic_key_last4,
|
|
94
|
+
anthropic_key_set_at = now(),
|
|
95
|
+
updated_by = EXCLUDED.updated_by,
|
|
96
|
+
updated_at = now()
|
|
97
|
+
RETURNING project`;
|
|
98
|
+
|
|
99
|
+
// setKey(pool, project, raw, builderId, { encrypt }) → { key_set: true } | { error }.
|
|
100
|
+
// The key goes through key.js sealProjectKey, the writer's half of the one reader the
|
|
101
|
+
// run uses, so what is stored is exactly what readProjectKey opens.
|
|
102
|
+
async function setKey(pool, project, raw, builderId, { encrypt } = {}) {
|
|
103
|
+
const sealed = sealProjectKey(raw, { encrypt });
|
|
104
|
+
if (sealed.error) return { error: sealed.error };
|
|
105
|
+
await pool.query(SET_KEY_SQL, [need(project), sealed.key_enc, sealed.last4, builderId]);
|
|
106
|
+
return { key_set: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const CLEAR_KEY_SQL = `
|
|
110
|
+
UPDATE market_watch_settings
|
|
111
|
+
SET anthropic_key_enc = NULL, anthropic_key_last4 = NULL, anthropic_key_set_at = NULL,
|
|
112
|
+
updated_by = $2, updated_at = now()
|
|
113
|
+
WHERE project = $1 AND anthropic_key_enc IS NOT NULL
|
|
114
|
+
RETURNING project`;
|
|
115
|
+
|
|
116
|
+
async function clearKey(pool, project, builderId) {
|
|
117
|
+
const { rows } = await pool.query(CLEAR_KEY_SQL, [need(project), builderId]);
|
|
118
|
+
return { key_set: false, removed: rows.length > 0 };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const KEY_SET_SQL = `SELECT (anthropic_key_enc IS NOT NULL) AS key_set FROM market_watch_settings WHERE project = $1`;
|
|
122
|
+
const ACTIVE_ITEMS_SQL = `SELECT count(*)::int AS n FROM market_watch_items WHERE project = $1 AND status = 'active'`;
|
|
123
|
+
// The newest run of ANY kind, the seed included: what the poller's clock counts from
|
|
124
|
+
// (store.js lastRun). The settings strip hides the seed run, the clock does not.
|
|
125
|
+
const LAST_RUN_SQL = `SELECT created_at FROM market_watch_runs WHERE project = $1 ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
126
|
+
|
|
127
|
+
// runEstimate(recentRuns, activeItems, ceilingUsd) → { usd, basis }. What the next run is
|
|
128
|
+
// likely to spend: the mean of the last few runs that ran and cost something, or, before
|
|
129
|
+
// there are any, every active item at its whole ceiling (an upper bound, said so).
|
|
130
|
+
function runEstimate(recentRuns, activeItems, ceilingUsd) {
|
|
131
|
+
const ran = recentRuns.filter((r) => (r.status === 'done' || r.status === 'partial') && r.cost_usd > 0).slice(0, ESTIMATE_FROM);
|
|
132
|
+
if (ran.length) return { usd: round2(ran.reduce((s, r) => s + r.cost_usd, 0) / ran.length), basis: 'recent' };
|
|
133
|
+
const n = Number(activeItems) || 0;
|
|
134
|
+
if (n > 0 && Number.isFinite(ceilingUsd) && ceilingUsd > 0) return { usd: round2(n * ceilingUsd), basis: 'ceiling' };
|
|
135
|
+
return { usd: null, basis: 'none' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// nextRun(enabled, lastRunAt, cadence, now) → { state, due_at }. 'paused' when the switch
|
|
139
|
+
// is off; 'soon' when a run is already due (the poller looks every hour); else the date.
|
|
140
|
+
function nextRun(enabled, lastRunAt, cadence, now) {
|
|
141
|
+
if (!enabled) return { state: 'paused', due_at: null };
|
|
142
|
+
const due = db.nextRunDue(lastRunAt, cadence);
|
|
143
|
+
if (!due || due.getTime() <= new Date(now).getTime()) return { state: 'soon', due_at: null };
|
|
144
|
+
return { state: 'scheduled', due_at: due.toISOString() };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// readView(pool, project, { now, ceilingUsd, storageConfigured }) — everything the page
|
|
148
|
+
// draws, and nothing secret: the key is a boolean.
|
|
149
|
+
async function readView(pool, project, { now = new Date(), ceilingUsd = null, storageConfigured = false } = {}) {
|
|
150
|
+
need(project);
|
|
151
|
+
const [view, { rows: [k] }, { rows: [items] }, { rows: [last] }] = await Promise.all([
|
|
152
|
+
db.settingsView(pool, { project, now, recent: RECENT_RUNS }),
|
|
153
|
+
pool.query(KEY_SET_SQL, [project]),
|
|
154
|
+
pool.query(ACTIVE_ITEMS_SQL, [project]),
|
|
155
|
+
pool.query(LAST_RUN_SQL, [project]),
|
|
156
|
+
]);
|
|
157
|
+
const s = view.settings;
|
|
158
|
+
const recent = view.recentRuns.map((r) => ({
|
|
159
|
+
id: r.id,
|
|
160
|
+
status: r.status,
|
|
161
|
+
reason: r.reason || null,
|
|
162
|
+
created_at: r.created_at,
|
|
163
|
+
items_total: r.items_total,
|
|
164
|
+
items_done: r.items_done,
|
|
165
|
+
cost_usd: r.cost_usd,
|
|
166
|
+
}));
|
|
167
|
+
const activeItems = items ? items.n : 0;
|
|
168
|
+
const estimate = runEstimate(recent, activeItems, ceilingUsd);
|
|
169
|
+
const spent = round2(view.spentThisMonthUsd);
|
|
170
|
+
const left = Math.max(0, s.monthly_cap_usd - spent);
|
|
171
|
+
return {
|
|
172
|
+
enabled: s.enabled === true,
|
|
173
|
+
monthly_cap_usd: s.monthly_cap_usd,
|
|
174
|
+
cadence: s.cadence,
|
|
175
|
+
configured: s.configured === true,
|
|
176
|
+
key_set: !!(k && k.key_set === true),
|
|
177
|
+
storage_configured: storageConfigured === true,
|
|
178
|
+
spent_this_month_usd: spent,
|
|
179
|
+
left_this_month_usd: round2(left),
|
|
180
|
+
month: monthLabel(now),
|
|
181
|
+
active_items: activeItems,
|
|
182
|
+
run_estimate_usd: estimate.usd,
|
|
183
|
+
run_estimate_basis: estimate.basis,
|
|
184
|
+
runs_left: estimate.usd ? Math.floor(left / estimate.usd) : null,
|
|
185
|
+
next_run: nextRun(s.enabled === true, last ? last.created_at : null, s.cadence, now),
|
|
186
|
+
recent_runs: recent,
|
|
187
|
+
cadences: CADENCES,
|
|
188
|
+
cap_bounds: { min: MIN_CAP_USD, max: MAX_CAP_USD },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function monthLabel(now) {
|
|
193
|
+
return new Date(now).toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }).toLowerCase();
|
|
194
|
+
}
|
|
195
|
+
function round2(n) { return Math.round(Number(n) * 100) / 100; }
|
|
196
|
+
function need(project) {
|
|
197
|
+
if (typeof project !== 'string' || !project) throw new Error('market-watch settings: a project key is required');
|
|
198
|
+
return project;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
parsePatch, writeSettings, setKey, clearKey, readView,
|
|
203
|
+
// pure, for the tests
|
|
204
|
+
runEstimate, nextRun,
|
|
205
|
+
CADENCES, MIN_CAP_USD, MAX_CAP_USD, PATCH_FIELDS,
|
|
206
|
+
SQL: { UPSERT_SQL, SET_KEY_SQL, CLEAR_KEY_SQL, KEY_SET_SQL },
|
|
207
|
+
};
|
|
@@ -80,12 +80,9 @@ const ALWAYS_ON_CORE = Object.freeze([
|
|
|
80
80
|
// investor page: its copy is Cloud Bongos's raise, so offering it to a new project would hand
|
|
81
81
|
// that project someone else's fundraise (task 1004852). `ai-studio` (task 1004916, ADR 0392)
|
|
82
82
|
// bills AI generation through Cloud Bongos's own payment processor account, so it stays on
|
|
83
|
-
// the hub until a project can bring its own billing. `market-watch`
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
// default:false module out of the opt-in catalog; the goal 1000137 task that ships the
|
|
87
|
-
// owner's on/off switch moves it to OPTIONAL_MODULES with owner-facing copy.
|
|
88
|
-
const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio', 'market-watch']);
|
|
83
|
+
// the hub until a project can bring its own billing. `market-watch` sat here while it was
|
|
84
|
+
// empty; task 1004938 shipped the owner's on/off switch and moved it to OPTIONAL_MODULES.
|
|
85
|
+
const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio']);
|
|
89
86
|
|
|
90
87
|
// The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
|
|
91
88
|
// every module an owner may actually choose, in the order the picker lists them,
|
|
@@ -148,6 +145,14 @@ const OPTIONAL_MODULES = Object.freeze([
|
|
|
148
145
|
summary: 'Commands to generate game tiles and sprites, score them against your art rules, and keep a style guide.',
|
|
149
146
|
aliases: Object.freeze(['tileset', 'graphics', 'artwork', 'retro', 'drawing', 'gamedev']),
|
|
150
147
|
}),
|
|
148
|
+
// task 1004938 (goal 1000137): offered once the owner's switch existed. It runs on the
|
|
149
|
+
// project's own Anthropic key, under a monthly cap the owner sets, and starts off.
|
|
150
|
+
Object.freeze({
|
|
151
|
+
key: 'market-watch',
|
|
152
|
+
label: 'Market Watch',
|
|
153
|
+
summary: 'A weekly AI look at your competitors, partners and topics, with a sourced log for each. It runs on your own Anthropic key, under a monthly cap you set.',
|
|
154
|
+
aliases: Object.freeze(['research', 'rivals', 'intelligence', 'analysis', 'trends', 'landscape']),
|
|
155
|
+
}),
|
|
151
156
|
]);
|
|
152
157
|
|
|
153
158
|
// The opt-in keys alone, for the membership tests the normalizer runs. Derived,
|
|
@@ -144,6 +144,7 @@ const STARTER_BUNDLES_FIXTURE = {
|
|
|
144
144
|
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.', aliases: ['chatroom', 'talk', 'messaging', 'community', 'announcements', 'notifications'] },
|
|
145
145
|
{ key: 'design-styles', label: 'Design styles', summary: 'Extra ways for the AI to style your pages: ready-made looks, a polish pass, and a way to author your own look.', aliases: ['theme', 'branding', 'visual', 'taste', 'aesthetic', 'layout'] },
|
|
146
146
|
{ key: 'pixel-art', label: 'Pixel-art tools', summary: 'Commands to generate game tiles and sprites, score them against your art rules, and keep a style guide.', aliases: ['tileset', 'graphics', 'artwork', 'retro', 'drawing', 'gamedev'] },
|
|
147
|
+
{ key: 'market-watch', label: 'Market Watch', summary: 'A weekly AI look at your competitors, partners and topics, with a sourced log for each. It runs on your own Anthropic key, under a monthly cap you set.', aliases: ['research', 'rivals', 'intelligence', 'analysis', 'trends', 'landscape'] },
|
|
147
148
|
],
|
|
148
149
|
};
|
|
149
150
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.10",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.10",
|
|
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.24.
|
|
3
|
+
"version": "1.24.10",
|
|
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
|
@@ -10889,5 +10889,11 @@
|
|
|
10889
10889
|
"id": "1004989",
|
|
10890
10890
|
"text": "Modules can now only be priced in cash: credits can no longer price or buy one, and modules that still have an old credit price stay listed as \"price coming\" while their authors are asked to set a cash price."
|
|
10891
10891
|
}
|
|
10892
|
+
],
|
|
10893
|
+
"1.24.10": [
|
|
10894
|
+
{
|
|
10895
|
+
"id": "1004938",
|
|
10896
|
+
"text": "The project owner now has a Market Watch settings page where they can switch it on or off, set the monthly AI spending cap and how often it runs, see this month's spend and recent runs, and paste the project's Anthropic key, w"
|
|
10897
|
+
}
|
|
10892
10898
|
]
|
|
10893
10899
|
}
|
|
@@ -201,6 +201,7 @@ const MODULE_GLOBS = {
|
|
|
201
201
|
// task 1004941: the rules page (proposals, decisions, the run's prompt fence)
|
|
202
202
|
'tests/market_watch_rules.mjs', 'tests/market_watch_rules_db.mjs',
|
|
203
203
|
'tests/market_watch_ask.mjs', 'tests/market_watch_ask_db.mjs',
|
|
204
|
+
'tests/market_watch_settings.mjs',
|
|
204
205
|
],
|
|
205
206
|
// --- builder prefs (wandering/skill/sound/render) + builder-needs ---
|
|
206
207
|
// Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
|
|
@@ -1323,9 +1323,9 @@ function mountInternalSurfaces(app) {
|
|
|
1323
1323
|
// Claude works for them. Every read behind it is the viewer's own and
|
|
1324
1324
|
// requireBuilder (GET /me and its /me/* prefs, /specialities), so sign-in is the
|
|
1325
1325
|
// whole gate; the governing reads it adds are gated by their own routes.
|
|
1326
|
-
//
|
|
1326
|
+
// tasks 1004921 `ai-studio` (the viewer's own routes), 1004938 `market-watch-settings` (no data; owner-only routes).
|
|
1327
1327
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1328
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ai-studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|commands)(?:\.html)?\/?$/;
|
|
1328
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ai-studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|market-watch-settings|commands)(?:\.html)?\/?$/;
|
|
1329
1329
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1330
1330
|
// page that embeds them.
|
|
1331
1331
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.10'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -318,7 +318,9 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
318
318
|
// the steering surfaces because that is what it is — the owner's third release gate.
|
|
319
319
|
// 'fleet' (task 1001949) follows it at the metic floor of `provisioning.fleet.manage`:
|
|
320
320
|
// what each hosted project costs is an operator surface, like the rest of this group.
|
|
321
|
-
|
|
321
|
+
// 'market-watch-settings' (task 1004938) is the owner's Market Watch switch and AI cap,
|
|
322
|
+
// archon like its four routes, and dropped where the market-watch module is off.
|
|
323
|
+
assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'market-watch-settings', 'government']);
|
|
322
324
|
});
|
|
323
325
|
|
|
324
326
|
test('Economy lives in Docs and points at the reward-schedule page', () => {
|
|
@@ -750,7 +752,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
|
|
|
750
752
|
// block (task 1004713). These drive the real shell and the real gov-positions.js.
|
|
751
753
|
|
|
752
754
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
753
|
-
const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'government'];
|
|
755
|
+
const GOVERNING = ['board-room', 'city', 'watch', 'gate', 'sessions', 'project-settings', 'deploy', 'fleet', 'market-watch-settings', 'government'];
|
|
754
756
|
const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
|
|
755
757
|
const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
|
|
756
758
|
|
|
@@ -56,6 +56,10 @@ const ORACLE = {
|
|
|
56
56
|
// settings. Every read behind it is the viewer's own (GET /me, /me/* prefs,
|
|
57
57
|
// /specialities) and requireBuilder, so sign-in is the whole gate.
|
|
58
58
|
'/builders/my-agents': 'requireBuilderPage',
|
|
59
|
+
// task 1004938: the owner's Market Watch settings. Its read and writes are
|
|
60
|
+
// owner-only (atom + owner seat) (tests/market_watch_settings.mjs proves the 403s), and the
|
|
61
|
+
// shell carries no data, so sign-in is the page tier and the routes are the wall.
|
|
62
|
+
'/builders/market-watch-settings': 'requireBuilderPage',
|
|
59
63
|
'/builders/goals': 'requireBuilderPage',
|
|
60
64
|
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
61
65
|
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
@@ -31,6 +31,8 @@ const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/m
|
|
|
31
31
|
const MIGRATION_003 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
|
|
32
32
|
const run = require('../modules/market-watch/run.js');
|
|
33
33
|
const store = require('../modules/market-watch/store.js');
|
|
34
|
+
const settings = require('../modules/market-watch/settings.js');
|
|
35
|
+
const key = require('../modules/market-watch/key.js');
|
|
34
36
|
|
|
35
37
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
36
38
|
try {
|
|
@@ -295,6 +297,31 @@ try {
|
|
|
295
297
|
assert.equal(await store.openRun(pool, P), null);
|
|
296
298
|
});
|
|
297
299
|
|
|
300
|
+
// task 1004938: the owner's settings writes, on the real table, read back by the run's
|
|
301
|
+
// own readers (store.readSettings, key.readProjectKey).
|
|
302
|
+
await check('the settings page writes what the run reads: switch, cap, cadence and the sealed key', async () => {
|
|
303
|
+
const box = { encrypt: (s) => `v1.iv.tag.${Buffer.from(s).toString('base64url')}`, decrypt: (b) => Buffer.from(b.split('.')[3], 'base64url').toString() };
|
|
304
|
+
await q(`DELETE FROM market_watch_settings WHERE project = $1`, [OTHER]);
|
|
305
|
+
// A first write of one field creates the row with the defaults for the rest.
|
|
306
|
+
assert.deepEqual(await settings.writeSettings(pool, OTHER, { cadence: 'monthly' }, ALICE), { enabled: false, monthly_cap_usd: 20, cadence: 'monthly' });
|
|
307
|
+
assert.deepEqual(await settings.writeSettings(pool, OTHER, { enabled: true, monthly_cap_usd: 35.5 }, ALICE), { enabled: true, monthly_cap_usd: 35.5, cadence: 'monthly' });
|
|
308
|
+
const st = store.createStore(pool);
|
|
309
|
+
assert.equal((await st.readSettings(OTHER)).enabled, true);
|
|
310
|
+
await settings.writeSettings(pool, OTHER, { enabled: false }, BOB);
|
|
311
|
+
assert.deepEqual(await st.readSettings(OTHER), { enabled: false, monthly_cap_usd: 35.5, cadence: 'monthly', configured: true });
|
|
312
|
+
assert.deepEqual(await settings.setKey(pool, OTHER, 'sk-ant-api03-db-test-key-LMNO', ALICE, { encrypt: box.encrypt }), { key_set: true });
|
|
313
|
+
assert.equal(await key.readProjectKey(pool, OTHER, { decrypt: box.decrypt }), 'sk-ant-api03-db-test-key-LMNO');
|
|
314
|
+
const view = await settings.readView(pool, OTHER, { ceilingUsd: 0.27, storageConfigured: true });
|
|
315
|
+
assert.equal(view.key_set, true);
|
|
316
|
+
assert.ok(!JSON.stringify(view).includes('LMNO'), 'the view never carries the key or its last four');
|
|
317
|
+
assert.deepEqual(await settings.clearKey(pool, OTHER, ALICE), { key_set: false, removed: true });
|
|
318
|
+
assert.deepEqual(await settings.clearKey(pool, OTHER, ALICE), { key_set: false, removed: false });
|
|
319
|
+
assert.equal(await key.readProjectKey(pool, OTHER, { decrypt: box.decrypt }), null);
|
|
320
|
+
// P's row was never touched by OTHER's writes.
|
|
321
|
+
assert.notEqual((await st.readSettings(P)).monthly_cap_usd, 35.5);
|
|
322
|
+
await q(`DELETE FROM market_watch_settings WHERE project = $1`, [OTHER]);
|
|
323
|
+
});
|
|
324
|
+
|
|
298
325
|
await check('one open run per project, and the key must be a secret-box blob', async () => {
|
|
299
326
|
await q(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'queued', 'manual')`, [OTHER]);
|
|
300
327
|
await refused(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'running', 'poller')`, [OTHER], 'a second open run');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the loader, and an instance boots with it present and DISABLED (task 1004935, goal
|
|
3
3
|
// 1000137). DB-free, so it runs in the unit lane.
|
|
4
4
|
//
|
|
5
|
-
// Task 1004940 mounted its first routes (the weekly ask); task 1004936 gave it its tables and a one-shot hub seed
|
|
5
|
+
// Task 1004940 mounted its first routes (the weekly ask), task 1004938 the owner settings routes; task 1004936 gave it its tables and a one-shot hub seed
|
|
6
6
|
// poller (tests/market_watch_data.mjs covers those). What this file
|
|
7
7
|
// pins is everything the later Market Watch tasks will stand on:
|
|
8
8
|
// 1. the manifest validates, ships default:false, and declares spend.requiresPayer;
|
|
@@ -40,6 +40,7 @@ test('the manifest validates, ships disabled, declares a payer, and contributes
|
|
|
40
40
|
assert.equal(c.migrations, true);
|
|
41
41
|
for (const p of ['hub-seed', 'weekly-run', 'weekly-ask']) assert.ok(c.pollers.includes(p), `poller ${p}`);
|
|
42
42
|
assert.ok(c.routes.includes('ask'), 'the weekly ask routes');
|
|
43
|
+
assert.ok(c.routes.includes('settings'), 'the owner settings routes (task 1004938, tests/market_watch_settings.mjs)');
|
|
43
44
|
assert.ok(c.uiSections.includes('weekly-ask'), 'the weekly ask hall slice (hallWidgetScripts needs a uiSection)');
|
|
44
45
|
assert.ok(c.routes.includes('rules'), 'the rules page routes (task 1004941)');
|
|
45
46
|
});
|
|
@@ -72,14 +73,17 @@ test('the spend guard bites it: an open door with no payer cannot enable it; a c
|
|
|
72
73
|
assert.equal(mods.resolveModules({ instance: on, registry, spendGuard: { joinDoorOpen: false, payer: null }, warn: silent })[KEY], true);
|
|
73
74
|
});
|
|
74
75
|
|
|
75
|
-
test('it has a scope-roster key, and the creation picker
|
|
76
|
+
test('it has a scope-roster key, and the creation picker offers it, off, now its switch exists', () => {
|
|
76
77
|
assert.ok(Array.isArray(scopeMap.MODULE_GLOBS[KEY]) && scopeMap.MODULE_GLOBS[KEY].includes('modules/market-watch/'));
|
|
77
78
|
// Every entry is an exact path or a dir prefix (the touches[] convention has no
|
|
78
79
|
// wildcards), and this test's own path resolves to the module's scope.
|
|
79
80
|
for (const g of scopeMap.MODULE_GLOBS[KEY]) assert.ok(!g.includes('*'), `wildcard glob never matches: ${g}`);
|
|
80
81
|
assert.ok(scopeMap.MODULE_GLOBS[KEY].includes('tests/market_watch_module.mjs'));
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
// Task 1004938 shipped the owner's switch (routes/settings.js), so it left the hub-only
|
|
83
|
+
// list for the opt-in catalog, with owner-facing copy. Opt-in, never always-on.
|
|
84
|
+
assert.ok(!bundles.HUB_ONLY_MODULES.includes(KEY));
|
|
85
|
+
const offered = bundles.OPTIONAL_MODULES.find((m) => m.key === KEY);
|
|
86
|
+
assert.ok(offered && offered.label && offered.summary, 'offered with copy an owner can read');
|
|
83
87
|
assert.ok(!bundles.ALWAYS_ON_CORE.includes(KEY));
|
|
84
88
|
});
|
|
85
89
|
|