@bongos/core 1.24.8 → 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 +131 -86
- 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 +11 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
- package/docs/api/openapi.json +185 -6
- package/docs/api-reference.md +7 -3
- package/docs/architecture.md +2 -2
- package/docs/copy-inventory.md +58 -28
- package/docs/copy-registry.json +333 -33
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -3
- package/docs/page-inventory.json +34 -4
- package/docs/page-readings.json +294 -237
- package/migrations/core_287_module_prices_cash_only.sql +24 -0
- 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/modules.js +5 -0
- 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 +12 -0
- package/scripts/bongos/module-store-cli.js +21 -21
- package/scripts/bongos/module.js +8 -8
- package/src/bongos/module-listing.js +47 -20
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/module-store.js +20 -12
- package/src/bongos/routes/modules.js +31 -21
- 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/module_store_cli.mjs +14 -3
- package/tests/module_store_listing.mjs +65 -14
- package/tests/module_store_price.mjs +97 -26
- package/tests/module_store_registry_migration.mjs +11 -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
|
@@ -10883,5 +10883,17 @@
|
|
|
10883
10883
|
"id": "1004941",
|
|
10884
10884
|
"text": "Every member can now read the Market Watch rules step by step, propose a change with a reason, and the owner (or the government, by vote) approves it into a new rules version that the next weekly run uses."
|
|
10885
10885
|
}
|
|
10886
|
+
],
|
|
10887
|
+
"1.24.9": [
|
|
10888
|
+
{
|
|
10889
|
+
"id": "1004989",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
10886
10898
|
]
|
|
10887
10899
|
}
|
|
@@ -63,13 +63,17 @@ function storeError(res) {
|
|
|
63
63
|
// bongos module store — what the store lists (task 1003811)
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
65
|
|
|
66
|
-
const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--
|
|
66
|
+
const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--json]';
|
|
67
|
+
|
|
68
|
+
// Credits never price or buy a module (task 1004989, ADR 0385 decision 2).
|
|
69
|
+
const NO_CREDIT_PRICE = 'credits do not price modules: use cents';
|
|
67
70
|
|
|
68
71
|
// The query GET /store/modules takes, from argv. Returns { query } or { error }.
|
|
69
72
|
// The server checks the values too; this only refuses what is not a value at all.
|
|
70
73
|
function storeQuery(args) {
|
|
74
|
+
if (args.includes('--max-credits')) return { error: NO_CREDIT_PRICE };
|
|
71
75
|
const params = [];
|
|
72
|
-
for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents']
|
|
76
|
+
for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents']]) {
|
|
73
77
|
const i = args.indexOf(flag);
|
|
74
78
|
if (i === -1) continue;
|
|
75
79
|
const v = args[i + 1];
|
|
@@ -79,12 +83,12 @@ function storeQuery(args) {
|
|
|
79
83
|
return { query: params.length ? `?${params.join('&')}` : '' };
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
// A cash price, "free", or "price coming" for a module that still carries only an
|
|
87
|
+
// old credit price. Never a credit amount.
|
|
82
88
|
function formatPrice(price) {
|
|
83
89
|
if (!price || price.free) return 'free';
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (price.credits != null) parts.push(`${price.credits} credits`);
|
|
87
|
-
return parts.join(' or ') || 'free';
|
|
90
|
+
if (price.cents != null) return `$${(price.cents / 100).toFixed(2)}`;
|
|
91
|
+
return 'price coming';
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
function formatPlace(category) {
|
|
@@ -104,7 +108,7 @@ function formatStore(body) {
|
|
|
104
108
|
out.push(` sorted by ${part ? part.title : body.sort}`);
|
|
105
109
|
}
|
|
106
110
|
const max = body.max_price;
|
|
107
|
-
if (max) out.push(` at most
|
|
111
|
+
if (max && max.cents != null) out.push(` at most $${(max.cents / 100).toFixed(2)}`);
|
|
108
112
|
out.push('');
|
|
109
113
|
const listed = body.listed || [];
|
|
110
114
|
if (!listed.length) out.push(' Nothing is listed yet.');
|
|
@@ -141,22 +145,18 @@ async function cmdStore(args, { log = console.log, errlog = console.error, reque
|
|
|
141
145
|
// bongos module price — the author sets a module's price (task 1004894)
|
|
142
146
|
// ---------------------------------------------------------------------------
|
|
143
147
|
|
|
144
|
-
const PRICE_USAGE = 'usage: bongos module price <key>
|
|
148
|
+
const PRICE_USAGE = 'usage: bongos module price <key> --cents N (0 makes it free)';
|
|
145
149
|
|
|
146
|
-
// argv -> { key, cents
|
|
150
|
+
// argv -> { key, cents } or { error }. Pure.
|
|
147
151
|
function priceArgs(args) {
|
|
148
152
|
const key = args[0];
|
|
149
153
|
if (!key || key.startsWith('--') || !KEY_RE.test(key)) return { error: 'name the module: a lowercase kebab-case key' };
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
out[field] = Number(v);
|
|
157
|
-
}
|
|
158
|
-
if (out.cents === null && out.credits === null) return { error: 'give --cents, --credits or both' };
|
|
159
|
-
return out;
|
|
154
|
+
if (args.includes('--credits')) return { error: NO_CREDIT_PRICE };
|
|
155
|
+
const i = args.indexOf('--cents');
|
|
156
|
+
if (i === -1) return { error: 'give --cents' };
|
|
157
|
+
const v = args[i + 1];
|
|
158
|
+
if (!v || !/^\d{1,9}$/.test(v)) return { error: '--cents needs a whole number, 0 or more' };
|
|
159
|
+
return { key, cents: Number(v) };
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
async function cmdPrice(args, { log = console.log, errlog = console.error, request } = {}) {
|
|
@@ -166,10 +166,10 @@ async function cmdPrice(args, { log = console.log, errlog = console.error, reque
|
|
|
166
166
|
try { call = request || storeRequest(); }
|
|
167
167
|
catch (e) { errlog(`bongos module price: ${e.message}`); return 1; }
|
|
168
168
|
let res;
|
|
169
|
-
try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents
|
|
169
|
+
try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents } }); }
|
|
170
170
|
catch (e) { errlog(`bongos module price: the store could not be reached — ${e.message}`); return 1; }
|
|
171
171
|
if (!res.ok) { errlog(`bongos module price: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
|
|
172
|
-
const withFree = (p) => ({ ...p, free: p.cents === 0
|
|
172
|
+
const withFree = (p) => ({ ...p, free: p.cents === 0 });
|
|
173
173
|
const shown = formatPrice(withFree(res.data.price));
|
|
174
174
|
const was = res.data.previous ? formatPrice(withFree(res.data.previous)) : 'free (no price set)';
|
|
175
175
|
log(`✓ "${p.key}" now costs ${shown} (was ${was}).`);
|
package/scripts/bongos/module.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// model as the hall's Modules tab (src/module-loader/catalog.js), so the two
|
|
32
32
|
// surfaces cannot disagree about who built what.
|
|
33
33
|
//
|
|
34
|
-
// bongos module store [--sort <part>] [--max-cents N] [--
|
|
34
|
+
// bongos module store [--sort <part>] [--max-cents N] [--json]
|
|
35
35
|
// Task 1003811 (criterion wa5-curated-listing) — the STORE from the terminal:
|
|
36
36
|
// what the catalog lists, in its order. It asks GET /store/modules, the same
|
|
37
37
|
// read the hall's "In the store" group makes, and never filters, ranks or caps
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
// sub-category live in one place (src/bongos/module-listing.js), so the hall
|
|
40
40
|
// and the terminal cannot disagree about which modules are the best.
|
|
41
41
|
//
|
|
42
|
-
// bongos module price <key>
|
|
42
|
+
// bongos module price <key> --cents N (credits never price a module)
|
|
43
43
|
// Task 1004894 — the module's author sets its price in the store, in money
|
|
44
|
-
// (cents)
|
|
44
|
+
// (cents); 0 makes it free. A credit price is refused (task 1004989). The store keeps every
|
|
45
45
|
// change and shows the newest. A paid module is listed as not yet buyable until
|
|
46
46
|
// buying arrives (task 1003788).
|
|
47
47
|
//
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
// bongos module new <key> scaffold modules/<key>/
|
|
73
73
|
// bongos module new <key> --force overwrite an existing modules/<key>/
|
|
74
74
|
// bongos module list [--catalog] browse the module catalog (author + origin credit)
|
|
75
|
-
// bongos module store [--sort <part>] [--max-cents N]
|
|
75
|
+
// bongos module store [--sort <part>] [--max-cents N] what the store lists, best first
|
|
76
76
|
// bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
|
|
77
77
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
78
78
|
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
@@ -968,7 +968,7 @@ async function cmdPublish(args, {
|
|
|
968
968
|
log = console.log, errlog = console.error,
|
|
969
969
|
modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
|
|
970
970
|
} = {}) {
|
|
971
|
-
const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]
|
|
971
|
+
const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]';
|
|
972
972
|
const VALUED = new Set(['--out', '--cents', '--credits']);
|
|
973
973
|
const key = args.find((a, i) => !a.startsWith('-') && !VALUED.has(args[i - 1]));
|
|
974
974
|
if (!key) {
|
|
@@ -1335,9 +1335,9 @@ function printHelp(out = console.log) {
|
|
|
1335
1335
|
out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
|
|
1336
1336
|
out(' bongos module list [--catalog] [--search <text>] [--json]');
|
|
1337
1337
|
out(' Browse the module catalog — what you can enable, with author + origin credit');
|
|
1338
|
-
out(' bongos module store [--sort <part>] [--max-cents N] [--
|
|
1338
|
+
out(' bongos module store [--sort <part>] [--max-cents N] [--json]');
|
|
1339
1339
|
out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
|
|
1340
|
-
out(' bongos module price <key>
|
|
1340
|
+
out(' bongos module price <key> --cents N');
|
|
1341
1341
|
out(' Set your module\'s price in the store (0 in either makes it free)');
|
|
1342
1342
|
out(' bongos module remove <key> Take a store module off this project (switch it off first; its folder is kept aside)');
|
|
1343
1343
|
out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
|
|
@@ -1346,7 +1346,7 @@ function printHelp(out = console.log) {
|
|
|
1346
1346
|
out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
|
|
1347
1347
|
out(' bongos module submit <key> [--sign-off "Name <email>"]');
|
|
1348
1348
|
out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
|
|
1349
|
-
out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]
|
|
1349
|
+
out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]');
|
|
1350
1350
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1351
1351
|
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1352
1352
|
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|