@bongos/core 1.19.1081 → 1.20.2
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 +528 -193
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1666 -1558
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +169 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/rot_inbox.mjs +8 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
package/src/platform-server.js
CHANGED
|
@@ -57,6 +57,7 @@ const { isModuleEnabled } = require('./modules');
|
|
|
57
57
|
const { refreshOverrides } = require('./bongos/module-overrides');
|
|
58
58
|
const { schemaPending } = require('./bongos/schema-lag');
|
|
59
59
|
const { logger, requestIdMiddleware } = require('./bongos/logger');
|
|
60
|
+
const seams = require('./module-seams');
|
|
60
61
|
|
|
61
62
|
const PORT = Number(process.env.PORT) || 3000;
|
|
62
63
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
@@ -199,6 +200,32 @@ function buildPlatformApp() {
|
|
|
199
200
|
// same reqId. Mounted before any surface so nothing logs uncorrelated.
|
|
200
201
|
app.use(requestIdMiddleware);
|
|
201
202
|
|
|
203
|
+
// THE request.divert SEAM (task 1004300, ADR 0349). A module may register a provider that
|
|
204
|
+
// takes a request BEFORE any surface answers and either handles it or passes it on —
|
|
205
|
+
// today the npm-release module's version preview, which hands a browser holding its
|
|
206
|
+
// cookie to a second process. The provider is looked up on EVERY request, not once at
|
|
207
|
+
// build: modules mount inside mountInternalSurfaces below, after this line runs, so a
|
|
208
|
+
// lookup here at build time would always find nothing.
|
|
209
|
+
//
|
|
210
|
+
// WITH NO PROVIDER THIS IS ONE MAP LOOKUP AND next(): an instance that does not have such
|
|
211
|
+
// a module is served exactly as it was. A provider that throws is treated as absent for
|
|
212
|
+
// that request, never as a failed request — a broken preview must not take the live hall
|
|
213
|
+
// down with it — unless it had already started answering.
|
|
214
|
+
app.use((req, res, next) => {
|
|
215
|
+
const divert = seams.resolveOptional('request.divert');
|
|
216
|
+
if (!divert) return next();
|
|
217
|
+
let passed = false;
|
|
218
|
+
const pass = (e) => { passed = true; next(e); };
|
|
219
|
+
Promise.resolve()
|
|
220
|
+
.then(() => divert(req, res, pass))
|
|
221
|
+
.catch((err) => {
|
|
222
|
+
logger.error({ err }, 'request.divert provider failed — serving the request normally');
|
|
223
|
+
if (passed) return;
|
|
224
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
225
|
+
pass();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
202
229
|
// The whole internal surface — shared verbatim with server.js (ADR 0062 R51).
|
|
203
230
|
mountInternalSurfaces(app);
|
|
204
231
|
|
|
@@ -266,6 +293,18 @@ function buildPlatformApp() {
|
|
|
266
293
|
res.type('html').send(html);
|
|
267
294
|
});
|
|
268
295
|
}
|
|
296
|
+
// THE ACCOUNT PAGE (task 1004259, ADR 0337 D4.3): where a person edits what
|
|
297
|
+
// belongs to their platform account rather than to one project — handle, name,
|
|
298
|
+
// bio, links, and who can find them. Hub-only, unlike the doors above: every
|
|
299
|
+
// read and write it makes is a platform-identity route, and that module IS the
|
|
300
|
+
// hub flag (the stripApexOnly note above), so a child instance serves no such
|
|
301
|
+
// page rather than one whose every request 404s.
|
|
302
|
+
app.get('/account', (req, res, next) => {
|
|
303
|
+
if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
|
|
304
|
+
const html = landingPage('account.html');
|
|
305
|
+
if (!html) return next();
|
|
306
|
+
res.type('html').send(html);
|
|
307
|
+
});
|
|
269
308
|
// Assets fall through to the 404 tail when no module claims the apex root — the
|
|
270
309
|
// handler is not mounted at all rather than pointed at a missing directory.
|
|
271
310
|
const landingAssets = landingDir() && express.static(landingDir(), { index: false, fallthrough: true });
|
|
@@ -335,9 +335,13 @@ const GRANT_WORDS = {
|
|
|
335
335
|
active_recently: /active in the last 30 days/,
|
|
336
336
|
};
|
|
337
337
|
|
|
338
|
+
// The toggles live on the hub's account page since task 1004259 (ADR 0337 D4.3) —
|
|
339
|
+
// one inline-script page, so the copy and the markup are read from the same file.
|
|
340
|
+
const ACCOUNT_PAGE = join(ROOT, 'modules/public-landing/public/account.html');
|
|
341
|
+
|
|
338
342
|
test('the discoverable toggle names EXACTLY what it grants — checked against SLIVER_KEYS itself', () => {
|
|
339
|
-
const js = readFileSync(
|
|
340
|
-
const html =
|
|
343
|
+
const js = readFileSync(ACCOUNT_PAGE, 'utf8');
|
|
344
|
+
const html = js;
|
|
341
345
|
// Privacy spec D3 makes this a MUST: "The discoverable toggle's settings copy
|
|
342
346
|
// must state exactly this grant." It was checked against three transcribed
|
|
343
347
|
// phrases, and the transcription had gone stale in the direction that matters:
|
|
@@ -363,13 +367,13 @@ test('the discoverable toggle names EXACTLY what it grants — checked against S
|
|
|
363
367
|
'and the copy says what it does NOT grant');
|
|
364
368
|
assert.match(js, /the one thing a recruiter can see while your profile page is private/,
|
|
365
369
|
'a private account is told the sliver is the exception to "not found to every visitor" — the surprising half of D2/D3');
|
|
366
|
-
assert.match(html, /<
|
|
367
|
-
'the
|
|
370
|
+
assert.match(html, /<div id="acctBody" hidden>/,
|
|
371
|
+
'the controls ship hidden — they reveal only once the hub module answers for this account');
|
|
368
372
|
});
|
|
369
373
|
|
|
370
374
|
test('each toggle claims exactly what is enforced — and BOTH are now in force', () => {
|
|
371
|
-
const js = readFileSync(
|
|
372
|
-
const html =
|
|
375
|
+
const js = readFileSync(ACCOUNT_PAGE, 'utf8');
|
|
376
|
+
const html = js;
|
|
373
377
|
// A toggle may promise only what a read path actually enforces. The two rows
|
|
374
378
|
// reached that point one task apart. Task 1002967 wired VISIBILITY into every
|
|
375
379
|
// public read, so its row moved to the present tense — and it must also say
|
|
@@ -113,8 +113,10 @@ t('FAIL-CLOSED: a throwing matcher is a hit, and only an explicit false clears a
|
|
|
113
113
|
|
|
114
114
|
t('this wall is STRICTER than the validator, which is the point', () => {
|
|
115
115
|
// An Archon clears validate.js's Metic+ rank floor on a protected scope...
|
|
116
|
+
// Through mergeForValidation, as the PATCH route does: the validator refuses
|
|
117
|
+
// a stored row's server-owned columns as unknown fields (task 1004007).
|
|
116
118
|
const verdict = validate.validateAgentDefinition(
|
|
117
|
-
|
|
119
|
+
authoring.mergeForValidation(DB_ROW, { scope_modules: ['government'] }),
|
|
118
120
|
{ authorRank: 'archon', allowedModules: ['government'], protectedModules: PROTECTED_MODULES, isProtectedPath: () => false },
|
|
119
121
|
);
|
|
120
122
|
assert.equal(verdict.ok, true, 'the rank floor admits an Archon');
|
|
@@ -126,6 +128,18 @@ t('this wall is STRICTER than the validator, which is the point', () => {
|
|
|
126
128
|
);
|
|
127
129
|
});
|
|
128
130
|
|
|
131
|
+
t('the fields the routes let through are exactly the fields the validator accepts (task 1004007)', () => {
|
|
132
|
+
// The validator refuses any key outside its list; the routes project onto
|
|
133
|
+
// AUTHORABLE_FIELDS. If the two drift, either an authorable field is refused
|
|
134
|
+
// on every write, or the validator's list names something no route can set.
|
|
135
|
+
const v = validate.validateAgentDefinition(
|
|
136
|
+
authoring.mergeForValidation(DB_ROW, {}),
|
|
137
|
+
{ authorRank: 'metic', allowedModules: [], protectedModules: [] },
|
|
138
|
+
);
|
|
139
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
140
|
+
assert.deepEqual([...Object.keys(v.value)].sort(), [...authoring.AUTHORABLE_FIELDS].sort());
|
|
141
|
+
});
|
|
142
|
+
|
|
129
143
|
console.log('\nrule 4 — the sync owns its own rows:');
|
|
130
144
|
|
|
131
145
|
t('a file-sourced definition is not editable here, and the reason names the file', () => {
|
package/tests/agents_sync.mjs
CHANGED
|
@@ -140,12 +140,22 @@ t('a file claiming author_rank cannot promote itself', () => {
|
|
|
140
140
|
const p = sync.planOne(claiming, {
|
|
141
141
|
authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'],
|
|
142
142
|
});
|
|
143
|
-
|
|
143
|
+
// Since task 1004007 the file is not even imported: author_rank is not a field
|
|
144
|
+
// of a definition, so the validator refuses it BY NAME rather than dropping it.
|
|
145
|
+
// That is a stronger form of this rule than the flagged import it used to be.
|
|
146
|
+
assert.equal(p.action, 'skip', 'a file claiming author_rank is refused, not imported');
|
|
147
|
+
assert.match(p.reason, /author_rank/);
|
|
144
148
|
// NULL, not 'xenos': this definition reaches a protected module, so no
|
|
145
|
-
// git-derived rank is stamped at all (stampableAuthorRank).
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
// git-derived rank is stamped at all (stampableAuthorRank).
|
|
150
|
+
assert.equal(p.authorRank ?? null, null, 'nothing git-derived is stamped on a protected-scope row');
|
|
151
|
+
// And without the claim, the same scope still imports only flagged: the
|
|
152
|
+
// protected wall is what holds, not the refusal above.
|
|
153
|
+
const plain = sync.planOne(
|
|
154
|
+
sync.parseAgentFile('---\nname: x\ntrigger: on-demand\nscope_modules: [government]\n---\nbody', {}),
|
|
155
|
+
{ authorRank: 'xenos', allowedModules: ['government'], protectedModules: ['government'] },
|
|
156
|
+
);
|
|
157
|
+
assert.equal(plain.action, 'flagged');
|
|
158
|
+
assert.match(plain.scopeViolation, /metic\+ author/i);
|
|
149
159
|
});
|
|
150
160
|
|
|
151
161
|
t('THE ESCALATION PATH: a spoofed git identity cannot arm a protected scope', () => {
|
|
@@ -447,5 +457,96 @@ t('migrate.sh invokes agents-sync, module-gated and fail-open', () => {
|
|
|
447
457
|
assert.doesNotMatch(call.slice(0, 200), /exit 1/, 'no exit 1 on the agents-sync path');
|
|
448
458
|
});
|
|
449
459
|
|
|
460
|
+
// ---- task 1004007: a file-declared filter and sink SURVIVE the round trip ----
|
|
461
|
+
//
|
|
462
|
+
// Tested at the round-trip altitude on purpose: file text in, the VALIDATOR's
|
|
463
|
+
// normalized row out, and then the event router's own reading of that row. A
|
|
464
|
+
// test over parseAgentFile alone stayed green through the whole bug, because
|
|
465
|
+
// the parser kept `touches` — it was the validator that dropped it.
|
|
466
|
+
{
|
|
467
|
+
const router = require('../modules/agents/pollers/event-router.js');
|
|
468
|
+
const compile = (globs) => (file) => globs.some((g) => file.startsWith(g.replace(/\*\*.*$/, '')));
|
|
469
|
+
const roundTrip = (text, name = 'review-lens') => validate.validateAgentDefinition(
|
|
470
|
+
sync.parseAgentFile(text, { name }),
|
|
471
|
+
{ authorRank: 'archon', allowedModules: [], protectedModules: [], isProtectedPath: () => false },
|
|
472
|
+
);
|
|
473
|
+
const LINES = [
|
|
474
|
+
'---', 'title: Review lens', 'trigger: event', 'event: task.shipped',
|
|
475
|
+
'touches:', ' - docs/**', ' - modules/hall-ui/**', 'sink: discord', '---',
|
|
476
|
+
'You review what shipped.', '',
|
|
477
|
+
];
|
|
478
|
+
const FILE = LINES.join('\n');
|
|
479
|
+
const without = (...drop) => LINES.filter((l) => !drop.includes(l)).join('\n');
|
|
480
|
+
|
|
481
|
+
t('a file-declared touches[] filter reaches trigger_spec and the router reads it (task 1004007)', () => {
|
|
482
|
+
const v = roundTrip(FILE);
|
|
483
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
484
|
+
assert.deepEqual(v.value.trigger_spec.touches, ['docs/**', 'modules/hall-ui/**']);
|
|
485
|
+
assert.deepEqual(router.declaredGlobs(v.value), ['docs/**', 'modules/hall-ui/**']);
|
|
486
|
+
// The point: a ship that touched nothing in scope must NOT fire.
|
|
487
|
+
assert.equal(router.matchesTouches(v.value, ['src/bongos/db.js'], compile).match, false);
|
|
488
|
+
assert.equal(router.matchesTouches(v.value, ['docs/voice.md'], compile).match, true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
t('a file-declared sink reaches trigger_spec and the router routes to it (task 1004007)', () => {
|
|
492
|
+
const v = roundTrip(FILE);
|
|
493
|
+
assert.equal(v.value.trigger_spec.sink, 'discord');
|
|
494
|
+
assert.equal(router.sinkNameFor(v.value), 'discord');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
t('touches_globs is the same filter as touches (the router reads both spellings)', () => {
|
|
498
|
+
const v = roundTrip(FILE.replace('touches:', 'touches_globs:'));
|
|
499
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
500
|
+
assert.deepEqual(router.declaredGlobs(v.value), ['docs/**', 'modules/hall-ui/**']);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
t('a file-declared cadence reaches trigger_spec, so a cron agent can be written as a file', () => {
|
|
504
|
+
const v = roundTrip(['---', 'trigger: cron', 'cadence: daily', '---', 'A digest.'].join('\n'), 'digest');
|
|
505
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
506
|
+
assert.equal(v.value.trigger_spec.cadence, 'daily');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
t('a definition with no filter still means every ship: the default is unchanged', () => {
|
|
510
|
+
const v = roundTrip(without('touches:', ' - docs/**', ' - modules/hall-ui/**'));
|
|
511
|
+
assert.equal(v.ok, true, JSON.stringify(v.errors));
|
|
512
|
+
assert.equal(v.value.trigger_spec.touches, undefined);
|
|
513
|
+
assert.equal(router.matchesTouches(v.value, ['src/bongos/db.js'], compile).reason, 'no_filter_declared');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
t('a malformed filter is REFUSED, never widened silently to fire on every ship', () => {
|
|
517
|
+
// A scalar is what "touches: docs/**" parses to. The router treats a non-list
|
|
518
|
+
// as ABSENT, i.e. every ship, so it has to be caught at authoring time.
|
|
519
|
+
const text = without(' - docs/**', ' - modules/hall-ui/**').replace('touches:', 'touches: docs/**');
|
|
520
|
+
const v = roundTrip(text);
|
|
521
|
+
assert.equal(v.ok, false);
|
|
522
|
+
assert.ok(v.errors.some((e) => e.code === 'trigger_touches_invalid'), JSON.stringify(v.errors));
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
t('an unknown top-level key is refused, not dropped: a typo is an error, not a default', () => {
|
|
526
|
+
const v = roundTrip(FILE.replace('sink: discord', 'sink: discord\ntouchess: [docs/**]'));
|
|
527
|
+
assert.equal(v.ok, false);
|
|
528
|
+
const e = v.errors.find((x) => x.code === 'unknown_field');
|
|
529
|
+
assert.ok(e, JSON.stringify(v.errors));
|
|
530
|
+
assert.match(e.message, /touchess/);
|
|
531
|
+
// and the exact probe from the task: a top-level touches on a row handed to
|
|
532
|
+
// the validator directly no longer returns ok with the filter gone.
|
|
533
|
+
const direct = validate.validateAgentDefinition(
|
|
534
|
+
{ name: 'x', persona: 'p', trigger_type: 'event', trigger_spec: { event: 'task.shipped' }, touches: ['src/**'] },
|
|
535
|
+
{ authorRank: 'archon', allowedModules: [], protectedModules: [] },
|
|
536
|
+
);
|
|
537
|
+
assert.equal(direct.ok, false, 'a touches that lands nowhere must not pass silently');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
t('every agent file this repo ships still validates under the stricter check', () => {
|
|
541
|
+
const dir = path.join(process.cwd(), '.claude', 'agents');
|
|
542
|
+
const files = fs.readdirSync(dir).filter((x) => x.endsWith('.md'));
|
|
543
|
+
assert.ok(files.length >= 3, 'expected the shipped agent files');
|
|
544
|
+
for (const x of files) {
|
|
545
|
+
const v = roundTrip(fs.readFileSync(path.join(dir, x), 'utf8'), x.replace(/\.md$/, ''));
|
|
546
|
+
assert.equal(v.ok, true, x + ': ' + JSON.stringify(v.errors));
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
450
551
|
console.log(`\nagents_sync: ${passed} passed, ${failed} failed`);
|
|
451
552
|
process.exit(failed ? 1 : 0);
|
|
@@ -477,6 +477,51 @@ await test('a hostile getter cannot escape the validator', () => {
|
|
|
477
477
|
assert.equal(r.ok, false);
|
|
478
478
|
});
|
|
479
479
|
|
|
480
|
+
await test('an unknown top-level key is refused by name, not dropped (task 1004007)', () => {
|
|
481
|
+
// The validator's normalized row names a fixed set of fields, so an extra key
|
|
482
|
+
// used to pass with ok:true and vanish, which is how a file's top-level
|
|
483
|
+
// touches: filter became "fires on every ship".
|
|
484
|
+
const r = validateAgentDefinition(
|
|
485
|
+
{ name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped' }, touches: ['src/**'] },
|
|
486
|
+
OPTS,
|
|
487
|
+
);
|
|
488
|
+
assert.equal(r.ok, false);
|
|
489
|
+
const e = r.errors.find((x) => x.code === 'unknown_field');
|
|
490
|
+
assert.ok(e, JSON.stringify(codes(r)));
|
|
491
|
+
assert.equal(e.field, 'touches');
|
|
492
|
+
assert.equal(r.value, null);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await test('a malformed touches filter is refused, because the router reads it as "every ship" (task 1004007)', () => {
|
|
496
|
+
for (const bad of ['docs/**', [], [''], [3], { a: 1 }]) {
|
|
497
|
+
const r = validateAgentDefinition(
|
|
498
|
+
{ name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped', touches: bad } },
|
|
499
|
+
OPTS,
|
|
500
|
+
);
|
|
501
|
+
assert.equal(r.ok, false, JSON.stringify(bad));
|
|
502
|
+
assert.ok(codes(r).includes('trigger_touches_invalid'), JSON.stringify(bad) + ' ' + JSON.stringify(codes(r)));
|
|
503
|
+
}
|
|
504
|
+
const good = validateAgentDefinition(
|
|
505
|
+
{ name: 'historian', persona: 'x', trigger_type: 'event', trigger_spec: { event: 'task.shipped', touches_globs: ['docs/**'] } },
|
|
506
|
+
OPTS,
|
|
507
|
+
);
|
|
508
|
+
assert.equal(good.ok, true, JSON.stringify(codes(good)));
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await test('a Proxy whose ownKeys trap throws cannot escape the validator (task 1004007)', () => {
|
|
512
|
+
// The unknown-field check is the one place the validator enumerates the
|
|
513
|
+
// definition, so it is the one place a throwing ownKeys trap could escape.
|
|
514
|
+
const hostile = new Proxy(
|
|
515
|
+
{ name: 'historian', persona: 'x', trigger_type: 'on-demand' },
|
|
516
|
+
{ ownKeys() { throw new Error('boom'); } },
|
|
517
|
+
);
|
|
518
|
+
let r;
|
|
519
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition(hostile, OPTS); });
|
|
520
|
+
assert.equal(r.ok, false);
|
|
521
|
+
assert.ok(codes(r).includes('keys_unreadable'), JSON.stringify(codes(r)));
|
|
522
|
+
assert.equal(r.value, null);
|
|
523
|
+
});
|
|
524
|
+
|
|
480
525
|
await test('EVERY error carries a code, a field and a human message', () => {
|
|
481
526
|
const r = validateAgentDefinition(
|
|
482
527
|
{ name: 'BAD NAME', trigger_type: 'webhook', trigger_spec: 'nope', scope_modules: ['government'] },
|
package/tests/api_path_404.mjs
CHANGED
|
@@ -172,5 +172,11 @@ t('rebaseRequiredHint: still leads with /strand-fix — merge is the manual fall
|
|
|
172
172
|
'the command that works on any machine must still come first (task 1003768)');
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// task 1004370: when main's CI is red for a reason outside every branch, the gated
|
|
176
|
+
// builder must learn from the refusal itself that the fix is still claimable.
|
|
177
|
+
t('rebaseRequiredHint: names the CI-unblock exit the gate lets through', () => {
|
|
178
|
+
assert.match(rebaseRequiredHint([{ id: 7 }]), /P1 kind=infra task passes this gate/);
|
|
179
|
+
});
|
|
180
|
+
|
|
175
181
|
console.log(`\napi_path_404: ${passed} passed, ${failed} failed`);
|
|
176
182
|
process.exit(failed ? 1 : 0);
|
|
@@ -270,6 +270,13 @@ function loopHarness({ rows, maxLoops = 6 }) {
|
|
|
270
270
|
// network call is not a unit suite, it is a flake waiting for a slow day.
|
|
271
271
|
writeHeartbeat: () => true,
|
|
272
272
|
postHeartbeat: async () => true,
|
|
273
|
+
// These tests drive the BACKOFF LADDER, not the upgrade path (task 1004374).
|
|
274
|
+
// forever() checks for code drift once a loop, and the generic run() stub
|
|
275
|
+
// above answers rev-parse and ls-remote with different output — which reads
|
|
276
|
+
// as "main moved" and exits the loop before it can ever reach probing.
|
|
277
|
+
// Declaring no drift keeps each test about the one thing it is testing;
|
|
278
|
+
// tests/autobongos_loop.mjs covers the drift exit itself.
|
|
279
|
+
codeDrifted: async () => false,
|
|
273
280
|
};
|
|
274
281
|
return { deps, waits: emitted, recorded, opts: { goals: [], maxTasks: 3, maxLoops } };
|
|
275
282
|
}
|
|
@@ -664,7 +664,7 @@ test('workerEnv passes Claude auth through, and still refuses the rest', () => {
|
|
|
664
664
|
// cap has to work, and it has to survive a restart: the count comes from the
|
|
665
665
|
// run log, not from memory.
|
|
666
666
|
|
|
667
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
667
|
+
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
|
|
668
668
|
import { tmpdir } from 'node:os';
|
|
669
669
|
import { join } from 'node:path';
|
|
670
670
|
|
|
@@ -768,3 +768,138 @@ test('countWorkedSince stops at the first row older than the mark', () => {
|
|
|
768
768
|
assert.equal(runner.countWorkedSince(8000), 1);
|
|
769
769
|
delete process.env.AUTOBONGOS_LOG_FILE;
|
|
770
770
|
});
|
|
771
|
+
|
|
772
|
+
// --- a --forever runner must be able to take a fix (task 1004374) -----------
|
|
773
|
+
//
|
|
774
|
+
// autobongos-service.cmd refreshes the checkout only BEFORE launching node, and
|
|
775
|
+
// --forever never returns (task 1003904). Together: a process never re-reads its
|
|
776
|
+
// code, a shipped fix never reaches the runner, and nothing restarts it. On the
|
|
777
|
+
// host this ran week-old code for four days across three landed fixes while the
|
|
778
|
+
// heartbeat reported it alive and the hall showed a healthy runner.
|
|
779
|
+
|
|
780
|
+
test('codeDrifted reports a drift when main has moved past the loaded head', async () => {
|
|
781
|
+
const calls = [];
|
|
782
|
+
const run = async (bin, args) => {
|
|
783
|
+
calls.push(args.join(' '));
|
|
784
|
+
if (args[0] === 'rev-parse') return { ok: true, stdout: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' };
|
|
785
|
+
return { ok: true, stdout: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/heads/main\n' };
|
|
786
|
+
};
|
|
787
|
+
const d = await runner.codeDrifted({ run });
|
|
788
|
+
assert.ok(d, 'a moved main is a drift');
|
|
789
|
+
assert.equal(d.from, 'aaaaaaaa');
|
|
790
|
+
assert.equal(d.to, 'bbbbbbbb');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test('codeDrifted is false when the loaded head IS the tip', async () => {
|
|
794
|
+
const loadedHead = async () => 'cccccccccccccccccccccccccccccccccccccccc';
|
|
795
|
+
const run = async (bin, args) => (args[0] === 'rev-parse'
|
|
796
|
+
? { ok: true, stdout: 'cccccccccccccccccccccccccccccccccccccccc\n' }
|
|
797
|
+
: { ok: true, stdout: 'cccccccccccccccccccccccccccccccccccccccc\trefs/heads/main\n' });
|
|
798
|
+
assert.equal(await runner.codeDrifted({ run, loadedHead }), false);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('codeDrifted returns null when the LOCAL head cannot be read', async () => {
|
|
802
|
+
// Injected rather than stubbed through run(): loadedHead caches into a
|
|
803
|
+
// module-level, and an earlier test in this file already populated it — so a
|
|
804
|
+
// run() stub that fails rev-parse is never consulted, and the case passes for
|
|
805
|
+
// an unrelated reason. That is exactly what the first cut of this test did.
|
|
806
|
+
const okTip = async () => ({ ok: true, stdout: 'dddddddddddddddddddddddddddddddddddddddd refs/heads/main' });
|
|
807
|
+
assert.equal(await runner.codeDrifted({ loadedHead: async () => null, run: okTip }), null,
|
|
808
|
+
'no local head means we cannot tell, and cannot-tell must never cycle the runner');
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('codeDrifted returns null when ORIGIN cannot be reached', async () => {
|
|
812
|
+
assert.equal(await runner.codeDrifted({ loadedHead: async () => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', run: async () => ({ ok: false }) }), null);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('codeDrifted returns null when the git call throws outright', async () => {
|
|
816
|
+
const thrown = async () => { throw new Error('no network'); };
|
|
817
|
+
assert.equal(await runner.codeDrifted({ loadedHead: async () => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', run: thrown }), null);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test('forever EXITS with the upgrade code on drift, and logs why', async () => {
|
|
821
|
+
const rows = [];
|
|
822
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 3 }, {
|
|
823
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
824
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
825
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
826
|
+
writeHeartbeat: () => true,
|
|
827
|
+
postHeartbeat: async () => true,
|
|
828
|
+
emit: () => {},
|
|
829
|
+
record: (r) => { rows.push(r); return r; },
|
|
830
|
+
now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
|
|
831
|
+
codeDrifted: async () => ({ from: '1111aaaa', to: '2222bbbb' }),
|
|
832
|
+
});
|
|
833
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE, 'the wrapper loops on exactly this code');
|
|
834
|
+
const exit = rows.find((r) => r.event === 'upgrade_exit');
|
|
835
|
+
assert.ok(exit, 'a silent exit is the failure mode task 1003904 forbids — it must say why');
|
|
836
|
+
assert.match(exit.reason, /main moved from 1111aaaa to 2222bbbb/);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('forever does NOT exit while the code is current', async () => {
|
|
840
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
|
|
841
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
842
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
843
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
844
|
+
writeHeartbeat: () => true,
|
|
845
|
+
postHeartbeat: async () => true,
|
|
846
|
+
emit: () => {},
|
|
847
|
+
record: (r) => r,
|
|
848
|
+
codeDrifted: async () => false,
|
|
849
|
+
});
|
|
850
|
+
assert.equal(code, 0, 'the no-terminal-stop rule still holds for every other case');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test('the wrapper loops on the upgrade code and only on that code', () => {
|
|
854
|
+
const raw = readFileSync(new URL('../scripts/gds/autobongos-service.cmd', import.meta.url), 'utf8');
|
|
855
|
+
// LIVE lines only, and no regex here on purpose. A first cut of this test
|
|
856
|
+
// matched the raw file text and passed happily against a commented-out
|
|
857
|
+
// "REM goto run" — which is precisely the regression it exists to catch.
|
|
858
|
+
const live = raw
|
|
859
|
+
.split('\n')
|
|
860
|
+
.map((l) => l.replace('\r', '').trim())
|
|
861
|
+
.filter((l) => l && !l.toUpperCase().startsWith('REM ') && !l.startsWith('::'));
|
|
862
|
+
const joined = live.join('\n');
|
|
863
|
+
assert.ok(live.includes(':run'), 'there must be a live label to go back to');
|
|
864
|
+
assert.ok(live.some((l) => l.startsWith('if "%EXITCODE%"=="75"')), 'and a live branch on the upgrade code');
|
|
865
|
+
assert.ok(live.includes('goto run'), 'and a live relaunch, not a commented-out one');
|
|
866
|
+
// Ordering: the relaunch must precede the final exit, or it can never run.
|
|
867
|
+
assert.ok(joined.indexOf('goto run') < joined.indexOf('exit /b %EXITCODE%'));
|
|
868
|
+
// And the refresh must sit INSIDE the loop, or a relaunch re-runs the old code,
|
|
869
|
+
// which is the entire bug.
|
|
870
|
+
assert.ok(joined.indexOf(':run') < joined.indexOf('git reset --quiet --hard origin/main'));
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('a runner that just started will NOT upgrade-exit, so a failed fetch cannot spin', async () => {
|
|
874
|
+
// The wrapper's reset is best-effort by design. Without this guard, starting
|
|
875
|
+
// behind means: see drift, exit, relaunch into the same state, forever.
|
|
876
|
+
let askedForDrift = false;
|
|
877
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
|
|
878
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
879
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
880
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
881
|
+
writeHeartbeat: () => true,
|
|
882
|
+
postHeartbeat: async () => true,
|
|
883
|
+
emit: () => {},
|
|
884
|
+
record: (r) => r,
|
|
885
|
+
now: () => Date.now(), // uptime is ~0, well under the floor
|
|
886
|
+
codeDrifted: async () => { askedForDrift = true; return { from: 'aaaa1111', to: 'bbbb2222' }; },
|
|
887
|
+
});
|
|
888
|
+
assert.equal(code, 0, 'it must keep running rather than bounce');
|
|
889
|
+
assert.equal(askedForDrift, false, 'and not even ask — the check costs a network call');
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test('past the uptime floor, a drift still exits', async () => {
|
|
893
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
|
|
894
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
895
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
896
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
897
|
+
writeHeartbeat: () => true,
|
|
898
|
+
postHeartbeat: async () => true,
|
|
899
|
+
emit: () => {},
|
|
900
|
+
record: (r) => r,
|
|
901
|
+
now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
|
|
902
|
+
codeDrifted: async () => ({ from: 'aaaa1111', to: 'bbbb2222' }),
|
|
903
|
+
});
|
|
904
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
905
|
+
});
|
package/tests/autonomy_gauge.mjs
CHANGED
|
@@ -398,3 +398,65 @@ test('a derived seven-day window is NOT paced against a boundary we invented', (
|
|
|
398
398
|
assert.equal(d.derived, true);
|
|
399
399
|
assert.equal(d.go, true);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// --- a derived mark must be in the CURRENT window (task 1004376) ------------
|
|
403
|
+
//
|
|
404
|
+
// Regression from the 1004178 fix, found on the host the moment the runner was
|
|
405
|
+
// restarted onto it. derivedSince reported the RETIRED reading's resets_at,
|
|
406
|
+
// which on an unattended host is days old — so the caller's cap counted work
|
|
407
|
+
// done days earlier, arrived pre-exhausted, and the runner held forever having
|
|
408
|
+
// correctly derived headroom. Exactly the state 1004178 existed to end.
|
|
409
|
+
|
|
410
|
+
test('a week-old retired reading yields a mark inside the CURRENT window', () => {
|
|
411
|
+
const now = 10_000_000;
|
|
412
|
+
const weekAgoReset = now - 7 * 24 * HOUR; // the retired boundary
|
|
413
|
+
const d = decideCapacity({
|
|
414
|
+
gauge: {
|
|
415
|
+
written_at: weekAgoReset - HOUR,
|
|
416
|
+
five_hour: { used_percentage: 90, resets_at: weekAgoReset, observed_at: weekAgoReset - HOUR },
|
|
417
|
+
},
|
|
418
|
+
now,
|
|
419
|
+
});
|
|
420
|
+
assert.equal(d.derived, true);
|
|
421
|
+
assert.notEqual(d.derivedSince, weekAgoReset, 'the retired boundary is exactly what must NOT come back');
|
|
422
|
+
assert.ok(now - d.derivedSince < 5 * HOUR, 'the mark must sit inside the current five-hour window');
|
|
423
|
+
assert.ok(d.derivedSince <= now, 'and never in the future');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('the advanced mark lands on a whole number of periods from the observed one', () => {
|
|
427
|
+
const now = 10_000_000;
|
|
428
|
+
const reset = now - (3 * 5 * HOUR) - HOUR; // three full windows, plus an hour
|
|
429
|
+
const d = decideCapacity({
|
|
430
|
+
gauge: { written_at: reset - 60, five_hour: { used_percentage: 80, resets_at: reset, observed_at: reset - 60 } },
|
|
431
|
+
now,
|
|
432
|
+
});
|
|
433
|
+
assert.equal(d.derivedSince, reset + 3 * 5 * HOUR, 'stepped forward by whole periods, not guessed');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('derivedSince takes the MOST RECENT boundary when both windows derive', () => {
|
|
437
|
+
const now = 10_000_000;
|
|
438
|
+
const d = decideCapacity({
|
|
439
|
+
gauge: {
|
|
440
|
+
written_at: now - 8 * 24 * HOUR,
|
|
441
|
+
five_hour: { used_percentage: 50, resets_at: now - 6 * HOUR, observed_at: now - 8 * 24 * HOUR },
|
|
442
|
+
seven_day: { used_percentage: 50, resets_at: now - 8 * 24 * HOUR, observed_at: now - 8 * 24 * HOUR },
|
|
443
|
+
},
|
|
444
|
+
now,
|
|
445
|
+
});
|
|
446
|
+
assert.equal(d.derived, true);
|
|
447
|
+
assert.ok(now - d.derivedSince < 5 * HOUR,
|
|
448
|
+
'the seven-day boundary would make the cap a one-time budget that never refills');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('a reading whose window is still running is unaffected by the advance', () => {
|
|
452
|
+
const now = 10_000_000;
|
|
453
|
+
const d = decideCapacity({
|
|
454
|
+
gauge: {
|
|
455
|
+
written_at: now - 10 * HOUR,
|
|
456
|
+
five_hour: { used_percentage: 92, resets_at: now + HOUR, observed_at: now - 10 * HOUR },
|
|
457
|
+
},
|
|
458
|
+
now,
|
|
459
|
+
});
|
|
460
|
+
assert.equal(d.go, false, 'still unknown — usage only grows inside a live window');
|
|
461
|
+
assert.equal(d.unknown, true);
|
|
462
|
+
});
|