@bongos/core 1.18.19 → 1.19.3
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 +547 -197
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/bin/bongos.js +33 -7
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/0161-publish-on-merge.md +34 -0
- package/docs/adr/README.md +9 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +181 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +6 -2
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/migrations/core_205_versions_track_default.sql +28 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +99 -69
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/bump-version.js +76 -0
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +77 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +5 -5
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +96 -13
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_cli.mjs +91 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/bump_version.mjs +71 -0
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +147 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +1 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/patch_gate_ordering.mjs +102 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
package/tests/provision.mjs
CHANGED
|
@@ -588,6 +588,176 @@ await ta('scaffoldStandaloneRepo: no owner token → falls back to the SSH origi
|
|
|
588
588
|
}
|
|
589
589
|
});
|
|
590
590
|
|
|
591
|
+
// ── ADOPT vs GREENFIELD onboarding (ADR 0121, task 1002562) ───────────────────────
|
|
592
|
+
// The runner used to run the greenfield scaffold unconditionally: `git init` a fresh tree
|
|
593
|
+
// and push HEAD:main. Against a repo that already has history that is an unrelated-history
|
|
594
|
+
// non-fast-forward, so adopting an existing repo could never succeed.
|
|
595
|
+
|
|
596
|
+
t('onboardMode: adopt only when the row says so; anything else → greenfield (the SAFE default)', () => {
|
|
597
|
+
assert.equal(P.onboardMode({ onboard_mode: 'adopt' }), 'adopt');
|
|
598
|
+
assert.equal(P.onboardMode({ onboard_mode: 'greenfield' }), 'greenfield');
|
|
599
|
+
// A row written BEFORE provisioning_004 added the column, and any unrecognized value,
|
|
600
|
+
// must resolve to greenfield — which refuses to push over existing history — never to
|
|
601
|
+
// adopt, which would start layering onto a repo nobody declared as pre-existing.
|
|
602
|
+
assert.equal(P.onboardMode({}), 'greenfield');
|
|
603
|
+
assert.equal(P.onboardMode({ onboard_mode: null }), 'greenfield');
|
|
604
|
+
assert.equal(P.onboardMode({ onboard_mode: 'ADOPT' }), 'greenfield');
|
|
605
|
+
assert.equal(P.onboardMode({ onboard_mode: 'something-else' }), 'greenfield');
|
|
606
|
+
assert.equal(P.onboardMode(null), 'greenfield');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
t('alreadyScaffolded: the marker is mode-dependent — package.json alone does NOT mean an adopt is done', () => {
|
|
610
|
+
const dir = mkdtempSync(join(tmpdir(), 'marker-'));
|
|
611
|
+
try {
|
|
612
|
+
assert.equal(P.alreadyScaffolded(dir, 'greenfield'), false, 'no package.json → not scaffolded');
|
|
613
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
|
|
614
|
+
|
|
615
|
+
// An ordinary existing Node project: package.json present, no core dep. Greenfield's
|
|
616
|
+
// marker would call this "done" — for ADOPT that is the bug this guards, because the
|
|
617
|
+
// layering would be skipped on the very first run and the instance never become bootable.
|
|
618
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'their-app', dependencies: { react: '^18' } }));
|
|
619
|
+
assert.equal(P.alreadyScaffolded(dir, 'greenfield'), true, 'greenfield: package.json IS the marker (unchanged)');
|
|
620
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false, 'adopt: an existing project is NOT already adopted');
|
|
621
|
+
|
|
622
|
+
// Once the layer has added the core dep, adopt IS done. Both scope aliases count.
|
|
623
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { react: '^18', '@bongos/core': 'file:vendor/x.tgz' } }));
|
|
624
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), true);
|
|
625
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@cloudbongos/core': '^1.0.0' } }));
|
|
626
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), true, 'the @cloudbongos alias counts too');
|
|
627
|
+
|
|
628
|
+
// Unparseable → NOT already done, so the adopt layer runs and reports it, rather than
|
|
629
|
+
// silently skipping on a file it could not read.
|
|
630
|
+
writeFileSync(join(dir, 'package.json'), '{ not json');
|
|
631
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
|
|
632
|
+
} finally {
|
|
633
|
+
rmSync(dir, { recursive: true, force: true });
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Drive the scaffold in a given mode against a fresh base and return the joined argv.
|
|
638
|
+
async function scaffoldInMode(mode, { token = null, seedRoot = null } = {}) {
|
|
639
|
+
const base = mkdtempSync(join(tmpdir(), `mode-${mode}-`));
|
|
640
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
641
|
+
P.CONFIG.standaloneBase = base;
|
|
642
|
+
try {
|
|
643
|
+
if (seedRoot) { mkdirSync(join(base, 'demo'), { recursive: true }); seedRoot(join(base, 'demo')); }
|
|
644
|
+
const { deps, cmds } = recorder({ apply: true });
|
|
645
|
+
const writes = [];
|
|
646
|
+
deps.writeFile = (p, body, opts) => { writes.push({ p, body, opts }); return { ok: true }; };
|
|
647
|
+
if (token) deps.getGithubToken = async () => ({ token, scope: 'read:user,repo' });
|
|
648
|
+
await P.scaffoldStandaloneRepo(
|
|
649
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: mode },
|
|
650
|
+
{ ...deps, instanceRoot: base },
|
|
651
|
+
);
|
|
652
|
+
return { joined: cmds.join('\n'), cmds, writes, root: join(base, 'demo') };
|
|
653
|
+
} finally {
|
|
654
|
+
P.CONFIG.standaloneBase = origBase;
|
|
655
|
+
rmSync(base, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
await ta('scaffoldStandaloneRepo(adopt): CLONES the existing repo instead of git-init-ing a fresh tree', async () => {
|
|
660
|
+
const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
|
|
661
|
+
assert.match(joined, /clone https:\/\/x-access-token@github\.com\/lars589\/demo\.git/, 'clones over the token URL (covers a PRIVATE repo the shared deploy key cannot read)');
|
|
662
|
+
assert.ok(!/git -C \S+ init -q/.test(joined), 'never git-inits a fresh tree on the adopt path — that is what produced the unrelated history');
|
|
663
|
+
assert.ok(!/checkout -B main/.test(joined), 'does not force-create the branch over the cloned one');
|
|
664
|
+
assert.match(joined, /init\.js --from .* --no-seed --adopt --accept /, 'materializes via init --adopt --accept (layering, accept-gated)');
|
|
665
|
+
// origin must end up as the SSH remote, like greenfield leaves it: the ongoing deploy
|
|
666
|
+
// (`git pull --ff-only origin main`, and the box timer's `git reset --hard origin/main`)
|
|
667
|
+
// reads origin, and the one-shot token URL cannot authenticate once its credential file
|
|
668
|
+
// is deleted.
|
|
669
|
+
assert.match(joined, /git -C \S+ remote set-url origin git@github-deploy:lars589\/demo\.git/, 'normalizes origin to the SSH remote after cloning');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// THE SAFETY PROPERTY. Since ADR 0155 this credential can write to EVERY repo the owner
|
|
673
|
+
// owns, so a --force here would be a route to destroying a user's work. A rejected push is
|
|
674
|
+
// the correct, recoverable outcome.
|
|
675
|
+
await ta('scaffoldStandaloneRepo(adopt): NEVER force-pushes and never passes --force to init', async () => {
|
|
676
|
+
const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
|
|
677
|
+
assert.ok(!/--force/.test(joined), 'no --force anywhere in the adopt argv');
|
|
678
|
+
assert.ok(!/ -f\b/.test(joined.replace(/rm -f /g, '')), 'no -f force shorthand (rm -f of the credential file excepted)');
|
|
679
|
+
assert.ok(!/\+refs|\+HEAD|:\+/.test(joined), 'no leading-+ refspec (the other way to force a push)');
|
|
680
|
+
assert.match(joined, /push https:\/\/x-access-token@github\.com\/lars589\/demo\.git HEAD:main/, 'a plain fast-forward push');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
await ta('scaffoldStandaloneRepo(greenfield): unchanged — git init + --force, no clone', async () => {
|
|
684
|
+
const { joined } = await scaffoldInMode('greenfield', { token: 'gho_TOK' });
|
|
685
|
+
assert.match(joined, /git -C \S+ init -q/, 'still git-inits the empty repo');
|
|
686
|
+
assert.match(joined, /checkout -B main/);
|
|
687
|
+
assert.match(joined, /init\.js --from .* --no-seed --force /, 'greenfield keeps --force (safe on an empty repo)');
|
|
688
|
+
assert.ok(!/--adopt/.test(joined), 'no --adopt on the greenfield path');
|
|
689
|
+
assert.ok(!/ clone /.test(joined), 'nothing to clone from an empty repo');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
await ta('scaffoldStandaloneRepo(adopt): no owner token → clones over the SSH remote (no credential helper)', async () => {
|
|
693
|
+
const { joined } = await scaffoldInMode('adopt'); // recorder default getGithubToken → null
|
|
694
|
+
assert.match(joined, /clone git@github-deploy:lars589\/demo\.git/, 'falls back to the SSH remote');
|
|
695
|
+
assert.ok(!/credential\.helper=/.test(joined), 'no credential helper without a token');
|
|
696
|
+
assert.match(joined, /push -u origin main/, 'and the SSH origin push');
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await ta('scaffoldStandaloneRepo(adopt): does NOT clobber an existing .gitignore — appends only what the deploy needs', async () => {
|
|
700
|
+
const { writes } = await scaffoldInMode('adopt', {
|
|
701
|
+
token: 'gho_TOK',
|
|
702
|
+
seedRoot: (root) => writeFileSync(join(root, '.gitignore'), 'their-secrets/\ncoverage/\n'),
|
|
703
|
+
});
|
|
704
|
+
const gi = writes.find((w) => /\.gitignore$/.test(w.p));
|
|
705
|
+
assert.ok(gi, 'wrote .gitignore');
|
|
706
|
+
assert.match(String(gi.body), /their-secrets\//, 'the owner’s existing rules survive');
|
|
707
|
+
assert.match(String(gi.body), /coverage\//);
|
|
708
|
+
assert.match(String(gi.body), /node_modules\//, 'and the entries `npm ci` + the deploy require are added');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await ta('scaffoldStandaloneRepo(adopt): a default branch that is not `main` fails LOUDLY, not silently', async () => {
|
|
712
|
+
// The rest of provisioning is main-only (git pull --ff-only origin main; the box deploy
|
|
713
|
+
// does git reset --hard origin/main), so layering onto another branch would push somewhere
|
|
714
|
+
// the deploy never reads — a half-working instance with no error.
|
|
715
|
+
const base = mkdtempSync(join(tmpdir(), 'mode-branch-'));
|
|
716
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
717
|
+
P.CONFIG.standaloneBase = base;
|
|
718
|
+
try {
|
|
719
|
+
const { deps } = recorder({ apply: true });
|
|
720
|
+
deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
|
|
721
|
+
deps.exec = (cmd) => ({ ok: true, stdout: /rev-parse --abbrev-ref HEAD/.test(cmd) ? 'master\n' : '' });
|
|
722
|
+
await assert.rejects(
|
|
723
|
+
() => P.scaffoldStandaloneRepo(
|
|
724
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
|
|
725
|
+
{ ...deps, instanceRoot: base },
|
|
726
|
+
),
|
|
727
|
+
/default branch is "master", not "main"/,
|
|
728
|
+
);
|
|
729
|
+
} finally {
|
|
730
|
+
P.CONFIG.standaloneBase = origBase;
|
|
731
|
+
rmSync(base, { recursive: true, force: true });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await ta('scaffoldStandaloneRepo(adopt): the credential file is removed even when a git step THROWS', async () => {
|
|
736
|
+
const base = mkdtempSync(join(tmpdir(), 'mode-cred-'));
|
|
737
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
738
|
+
P.CONFIG.standaloneBase = base;
|
|
739
|
+
try {
|
|
740
|
+
const { deps, cmds } = recorder({ apply: true });
|
|
741
|
+
deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
|
|
742
|
+
deps.exec = (cmd) => {
|
|
743
|
+
cmds.push(cmd);
|
|
744
|
+
if (/ push /.test(cmd)) throw new Error('remote rejected: non-fast-forward');
|
|
745
|
+
return { ok: true, stdout: '' };
|
|
746
|
+
};
|
|
747
|
+
await assert.rejects(
|
|
748
|
+
() => P.scaffoldStandaloneRepo(
|
|
749
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
|
|
750
|
+
{ ...deps, instanceRoot: base },
|
|
751
|
+
),
|
|
752
|
+
/non-fast-forward/,
|
|
753
|
+
);
|
|
754
|
+
assert.match(cmds.join('\n'), /rm -f [^\n]*bongos-gitcred-demo/, 'a failed push must not leave the token on disk');
|
|
755
|
+
} finally {
|
|
756
|
+
P.CONFIG.standaloneBase = origBase;
|
|
757
|
+
rmSync(base, { recursive: true, force: true });
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
591
761
|
await ta('provisionInstance is idempotent: an already-active instance is a no-op', async () => {
|
|
592
762
|
const { deps, cmds } = recorder();
|
|
593
763
|
const r = await P.provisionInstance({ ...coTenant, status: 'active' }, deps);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// tests/provisioning_onboard_mode.mjs
|
|
2
|
+
//
|
|
3
|
+
// The onboard_mode leg of brownfield onboarding (ADR 0121, task 1002562, migration
|
|
4
|
+
// provisioning_004). Before this, greenfield-vs-adopt existed ONLY as a query param on
|
|
5
|
+
// GET /provisioning/onboard-plan that chose which checklist TEXT to render — it was never
|
|
6
|
+
// persisted, so the control-plane runner ran the greenfield scaffold unconditionally and
|
|
7
|
+
// adopting an existing repo always failed as an unrelated-history non-fast-forward.
|
|
8
|
+
//
|
|
9
|
+
// Covers the two layers that carry the mode from the browser to the runner:
|
|
10
|
+
// • createInstance — normalization (unknown/absent → greenfield) + what reaches SQL
|
|
11
|
+
// • POST /instances — validation + threading, driven over a real mounted router
|
|
12
|
+
// • publicInstance — the mode is readable back out (coalesced for pre-004 rows)
|
|
13
|
+
//
|
|
14
|
+
// DB-free: the pool is a recording fake and the data-access fns the route needs are
|
|
15
|
+
// stubbed on the cached module object. No network.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/provisioning_onboard_mode.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = new URL('..', import.meta.url).pathname;
|
|
25
|
+
|
|
26
|
+
const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
|
|
27
|
+
|
|
28
|
+
let passed = 0, failed = 0;
|
|
29
|
+
function t(name, fn) {
|
|
30
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
31
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
32
|
+
}
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── createInstance: normalization + what actually reaches SQL ─────────────────────
|
|
39
|
+
// A recording pool: capture the INSERT's params so we assert the STORED value, not just
|
|
40
|
+
// the returned row (a normalization bug that only shows up in the column is the one that
|
|
41
|
+
// would silently mislabel a row forever).
|
|
42
|
+
function recordingDb(returnRow = {}) {
|
|
43
|
+
const calls = [];
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
query: async (text, params) => {
|
|
47
|
+
calls.push({ text, params });
|
|
48
|
+
return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo', ...returnRow }] };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await ta('createInstance: onboardMode "adopt" is persisted', async () => {
|
|
54
|
+
const db = recordingDb();
|
|
55
|
+
await provisioning.createInstance(db, {
|
|
56
|
+
ownerBuilderId: 7, slug: 'demo', targetRef: 'lars589/demo',
|
|
57
|
+
hostingShape: 'standalone', onboardMode: 'adopt',
|
|
58
|
+
});
|
|
59
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
60
|
+
assert.ok(insert, 'issued an INSERT');
|
|
61
|
+
assert.match(insert.text, /onboard_mode/, 'the column is in the INSERT');
|
|
62
|
+
assert.ok(insert.params.includes('adopt'), `'adopt' reached the INSERT params (got ${JSON.stringify(insert.params)})`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE default', async () => {
|
|
66
|
+
// Greenfield refuses to push over existing history (no --force anywhere), so an
|
|
67
|
+
// unrecognized value must land there. Defaulting to 'adopt' would put the layering path
|
|
68
|
+
// on a repo nobody declared as pre-existing.
|
|
69
|
+
for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
|
|
70
|
+
const db = recordingDb();
|
|
71
|
+
await provisioning.createInstance(db, {
|
|
72
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
|
|
73
|
+
});
|
|
74
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
75
|
+
assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
|
|
76
|
+
assert.ok(!insert.params.includes('adopt'), `onboardMode=${JSON.stringify(mode)} must NOT become adopt`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
t('ONBOARD_MODES matches the provisioning_004 CHECK constraint', () => {
|
|
81
|
+
assert.deepEqual(provisioning.ONBOARD_MODES, ['greenfield', 'adopt']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
t('publicInstance: surfaces onboard_mode, coalescing a pre-004 row to greenfield', () => {
|
|
85
|
+
assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: 'adopt' }).onboard_mode, 'adopt');
|
|
86
|
+
// A row read before the column existed (or a NULL) reads as greenfield — which is what
|
|
87
|
+
// it was, by construction: there was no other scaffold path.
|
|
88
|
+
assert.equal(provisioning.publicInstance({ id: 1 }).onboard_mode, 'greenfield');
|
|
89
|
+
assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: null }).onboard_mode, 'greenfield');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── POST /provisioning/instances over a real mounted router ───────────────────────
|
|
93
|
+
let lastCreateArgs = null;
|
|
94
|
+
provisioning.createInstance = async (_db, args) => {
|
|
95
|
+
lastCreateArgs = args;
|
|
96
|
+
return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, onboard_mode: args.onboardMode }, created: true };
|
|
97
|
+
};
|
|
98
|
+
provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
|
|
99
|
+
provisioning.recordEvent = async () => {};
|
|
100
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
101
|
+
|
|
102
|
+
const api = require(`${ROOT}/src/module-api.js`);
|
|
103
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
104
|
+
|
|
105
|
+
const express = require('express');
|
|
106
|
+
const provisioningRoutes = require(`${ROOT}/modules/provisioning/routes/provisioning.js`);
|
|
107
|
+
const app = express();
|
|
108
|
+
app.use(express.json());
|
|
109
|
+
app.use((_req, res, next) => {
|
|
110
|
+
res.fail = (code, opts, details) => {
|
|
111
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
112
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
113
|
+
};
|
|
114
|
+
next();
|
|
115
|
+
});
|
|
116
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
117
|
+
|
|
118
|
+
const server = app.listen(0);
|
|
119
|
+
await new Promise((r) => server.once('listening', r));
|
|
120
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
121
|
+
|
|
122
|
+
function post(path, body) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const payload = JSON.stringify(body);
|
|
125
|
+
const req = http.request(`${base}${path}`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
128
|
+
}, (res) => {
|
|
129
|
+
let d = '';
|
|
130
|
+
res.on('data', (c) => { d += c; });
|
|
131
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
132
|
+
});
|
|
133
|
+
req.on('error', reject);
|
|
134
|
+
req.end(payload);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
|
|
139
|
+
lastCreateArgs = null;
|
|
140
|
+
const res = await post('/provisioning/instances', {
|
|
141
|
+
slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', onboard_mode: 'adopt',
|
|
142
|
+
});
|
|
143
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
144
|
+
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
|
|
148
|
+
lastCreateArgs = null;
|
|
149
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone' });
|
|
150
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
151
|
+
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently coerced)', async () => {
|
|
155
|
+
// Coercing here would be worse than refusing: the caller believes it asked for one thing
|
|
156
|
+
// and the runner does another. The route names the valid set so the client can fix itself.
|
|
157
|
+
const res = await post('/provisioning/instances', {
|
|
158
|
+
slug: 'demo', hosting_shape: 'standalone', target_ref: 'a/b', onboard_mode: 'brownfield',
|
|
159
|
+
});
|
|
160
|
+
assert.equal(res.status, 400);
|
|
161
|
+
assert.equal(res.body.error.code, 'bad_onboard_mode');
|
|
162
|
+
assert.deepEqual(res.body.error.valid, ['greenfield', 'adopt']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no repo to layer onto', async () => {
|
|
166
|
+
// Without target_ref the runner would fall back to greenfield behavior on a row labelled
|
|
167
|
+
// adopt. Refuse at the door rather than store a lie.
|
|
168
|
+
for (const body of [
|
|
169
|
+
{ slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt' },
|
|
170
|
+
{ slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt', target_ref: ' ' },
|
|
171
|
+
]) {
|
|
172
|
+
const res = await post('/provisioning/instances', body);
|
|
173
|
+
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|
|
174
|
+
assert.equal(res.body.error.code, 'target_ref_required_for_adopt');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
server.close();
|
|
179
|
+
console.log(`\nprovisioning_onboard_mode: ${passed} passed, ${failed} failed`);
|
|
180
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -211,6 +211,9 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
|
|
|
211
211
|
const offenders = []; // …and are still publishable (the actual failure)
|
|
212
212
|
for (const name of fs.readdirSync(testsDir)) {
|
|
213
213
|
if (!/\.(mjs|js|cjs)$/.test(name)) continue;
|
|
214
|
+
// Skip self: the regex specimens below are deliberate full import forms;
|
|
215
|
+
// this file's real imports pull scripts/gds code only, never modules/.
|
|
216
|
+
if (name === 'publish_manifest.mjs') continue;
|
|
214
217
|
const rel = `tests/${name}`;
|
|
215
218
|
const bad = new Set();
|
|
216
219
|
let m; IMPORT_RE.lastIndex = 0;
|
|
@@ -225,9 +228,19 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
|
|
|
225
228
|
offenders.push(`${rel} imports excluded module(s) [${[...bad].sort().join(', ')}] but is still PUBLISHABLE — add it to CONTENT_EXCLUDE (task 2147)`);
|
|
226
229
|
}
|
|
227
230
|
}
|
|
228
|
-
// non-vacuous: the
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
+
// non-vacuous: prove the regex itself extracts a module key from every import
|
|
232
|
+
// form it claims to cover. A repo-state floor ("must see ≥7 host tests") went
|
|
233
|
+
// stale the moment the host-module tests moved to modules/<key>/tests/ under
|
|
234
|
+
// ADR 0107 — tests/ can legitimately contain zero live specimens (task 1002611).
|
|
235
|
+
for (const [specimen, key] of [
|
|
236
|
+
[`const x = require('../modules/game/db.js')`, 'game'],
|
|
237
|
+
[`import x from '../../modules/art-pipeline/pipeline/palette.js'`, 'art-pipeline'],
|
|
238
|
+
[`const y = await import('./modules/some-key/loader.js')`, 'some-key'],
|
|
239
|
+
]) {
|
|
240
|
+
IMPORT_RE.lastIndex = 0;
|
|
241
|
+
const m = IMPORT_RE.exec(specimen);
|
|
242
|
+
assert.ok(m && (m[2] || m[4]) === key, `import scan no longer matches: ${specimen}`);
|
|
243
|
+
}
|
|
231
244
|
assert.equal(offenders.length, 0, '\n ' + offenders.join('\n '));
|
|
232
245
|
});
|
|
233
246
|
|
|
@@ -58,7 +58,7 @@ function mkDeps({
|
|
|
58
58
|
refreshStaleImpl,
|
|
59
59
|
healAttempts = new Map(),
|
|
60
60
|
} = {}) {
|
|
61
|
-
const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], logs: [], errors: [] };
|
|
61
|
+
const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
|
|
62
62
|
// isSafeBranch mirrors github-push's pattern enough for the test (rejects
|
|
63
63
|
// option-like / traversal refs); we use the REAL module so the contract can't drift.
|
|
64
64
|
const githubPush = require('../modules/lifecycle/github-push.js');
|
|
@@ -108,23 +108,33 @@ function mkDeps({
|
|
|
108
108
|
return !!a;
|
|
109
109
|
},
|
|
110
110
|
// task 1547 per-task land proof. Mirrors the real github-push.landProvenForTask
|
|
111
|
-
// shape but routes its
|
|
111
|
+
// shape but routes its network hops (the ancestry compares) through the mock
|
|
112
112
|
// above so the sweep stays DB/network-free, and reuses the REAL prReferencesTask
|
|
113
113
|
// so that pure check can't drift. publishedSha (the server-pushed tip) is the
|
|
114
|
-
// robust proof; the
|
|
115
|
-
//
|
|
114
|
+
// robust proof; task 1002615 adds the merged-PR merge-commit proof (out-of-band /
|
|
115
|
+
// squash land) and gates the no-tip reference fallback on merged:true, because
|
|
116
|
+
// the sweep now consults this proof even while a PR is still in flight.
|
|
116
117
|
landProvenForTask: async ({ taskId, publishedSha, status }) => {
|
|
117
|
-
|
|
118
|
+
const hasTip = !!(publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha)));
|
|
119
|
+
if (hasTip) {
|
|
118
120
|
calls.ancestry.push(publishedSha);
|
|
119
121
|
const a = ancestryBySha[publishedSha];
|
|
120
122
|
if (a instanceof Error) throw a;
|
|
121
|
-
return
|
|
122
|
-
? { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` }
|
|
123
|
-
: { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
|
|
123
|
+
if (a) return { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` };
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
if (status && status.merged === true && githubPush.prReferencesTask(status, taskId)
|
|
126
|
+
&& status.merge_sha && /^[0-9a-f]{7,64}$/.test(String(status.merge_sha))) {
|
|
127
|
+
calls.ancestry.push(status.merge_sha);
|
|
128
|
+
const m = ancestryBySha[status.merge_sha];
|
|
129
|
+
if (m instanceof Error) throw m;
|
|
130
|
+
if (m) return { proven: true, reason: `merged PR for task ${taskId} — merge in main (out-of-band/squash land)` };
|
|
131
|
+
}
|
|
132
|
+
if (hasTip) {
|
|
133
|
+
return { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
|
|
134
|
+
}
|
|
135
|
+
return (status && status.merged === true && githubPush.prReferencesTask(status, taskId))
|
|
126
136
|
? { proven: true, reason: `matched PR references task ${taskId}` }
|
|
127
|
-
: { proven: false, reason: `no published tip and
|
|
137
|
+
: { proven: false, reason: `no published tip and no merged PR references task ${taskId}` };
|
|
128
138
|
},
|
|
129
139
|
},
|
|
130
140
|
// task.shipped is emitted on the kernel seam now; the discord module's listener
|
|
@@ -133,6 +143,9 @@ function mkDeps({
|
|
|
133
143
|
// broadcastImpl REPLACES the default push (the throwing-broadcast test relies
|
|
134
144
|
// on that to prove the reconcile stands even when the listener throws).
|
|
135
145
|
emit: async (event, task) => {
|
|
146
|
+
// task 1002606: record EVERY seam event so the strand push channel
|
|
147
|
+
// (task.strand-flagged) is observable alongside task.shipped.
|
|
148
|
+
calls.events.push({ event, payload: task });
|
|
136
149
|
if (event !== 'task.shipped') return;
|
|
137
150
|
if (broadcastImpl) return broadcastImpl(task);
|
|
138
151
|
calls.broadcast.push(task.id);
|
|
@@ -307,6 +320,50 @@ test('1002472 merged + deploy unconfigured but tip NOT in main → NO ship (1547
|
|
|
307
320
|
assert.equal(deps._calls.ship.length, 0);
|
|
308
321
|
});
|
|
309
322
|
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// task 1002615 (idea 1000613) — a land made OUT-OF-BAND must still ship.
|
|
325
|
+
// The 2026-07-29 reproduction: ship → confirmed → PR merged by the owner through
|
|
326
|
+
// the web UI → the work verified on main → the task sat at 'confirmed' for 9+ min
|
|
327
|
+
// (forever), builder unpaid, until a manual POST /ship.
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
const MERGE_SHA = 'c'.repeat(40);
|
|
330
|
+
|
|
331
|
+
test('1002615 web-UI squash merge (tip rewritten, PR merged) → ships via the merge-commit proof', async () => {
|
|
332
|
+
const deps = mkDeps({
|
|
333
|
+
rows: [{ task_id: 80, builder_id: 3, worktree_name: 'wt-squash', published_head_sha: NOT_ON_MAIN }],
|
|
334
|
+
statusByBranch: { 'claude/wt-squash': { ...landedPrFor(80), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
|
|
335
|
+
ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
|
|
336
|
+
});
|
|
337
|
+
const r = await reconciler.runSweep(deps);
|
|
338
|
+
assert.equal(r.reconciled, 1, 'a squash-rewritten land must not strand unpaid');
|
|
339
|
+
assert.deepEqual(deps._calls.ship, [{ taskId: 80, builderId: 3 }]);
|
|
340
|
+
assert.deepEqual(deps._calls.broadcast, [80]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('1002615 manual git merge to main, PR closed un-merged → tip ancestry ships it (prNotLanded no longer suppresses the proof)', async () => {
|
|
344
|
+
const deps = mkDeps({
|
|
345
|
+
rows: [{ task_id: 81, builder_id: 3, worktree_name: 'wt-oob', published_head_sha: ON_MAIN,
|
|
346
|
+
confirmed_at: new Date().toISOString() }],
|
|
347
|
+
statusByBranch: { 'claude/wt-oob': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'pending' } },
|
|
348
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
349
|
+
});
|
|
350
|
+
const r = await reconciler.runSweep(deps);
|
|
351
|
+
assert.equal(r.reconciled, 1, 'work on main behind a dead PR must still land');
|
|
352
|
+
assert.deepEqual(deps._calls.ship, [{ taskId: 81, builderId: 3 }]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('1002615 the reuse guard holds: merged PR for a DIFFERENT task + tip not in main → NO ship', async () => {
|
|
356
|
+
const deps = mkDeps({
|
|
357
|
+
rows: [{ task_id: 82, builder_id: 3, worktree_name: 'wt-reused', published_head_sha: NOT_ON_MAIN,
|
|
358
|
+
confirmed_at: new Date().toISOString() }],
|
|
359
|
+
statusByBranch: { 'claude/wt-reused': { ...landedPrFor(999), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
|
|
360
|
+
ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
|
|
361
|
+
});
|
|
362
|
+
const r = await reconciler.runSweep(deps);
|
|
363
|
+
assert.equal(r.reconciled, 0, 'the merge-commit proof must bind to THIS task');
|
|
364
|
+
assert.equal(deps._calls.ship.length, 0);
|
|
365
|
+
});
|
|
366
|
+
|
|
310
367
|
test('getPublishStatus throws (transient) → swallowed, sweep continues to the next task', async () => {
|
|
311
368
|
const deps = mkDeps({
|
|
312
369
|
rows: [
|
|
@@ -906,6 +963,120 @@ test('1002461 the WARN re-arms once a credential appears and then disappears aga
|
|
|
906
963
|
assert.equal(badAgain._calls.errors.length, 1, 'a credential LOSS after a healthy period must warn again');
|
|
907
964
|
});
|
|
908
965
|
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// task 1002606 (finding P-3, the DETECTION half) — a strand reaches its builder
|
|
968
|
+
// when it is DETECTED, not at the 30-minute stale WARN. Definitive shapes flag
|
|
969
|
+
// needs_rebase on the sweep that sees them AND emit task.strand-flagged (the
|
|
970
|
+
// push channel Discord listens on); ambiguous shapes keep the 30-min grace.
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
test('1002606 a hand-written conflict flags needs_rebase IMMEDIATELY (not at the 30-min WARN), with the file list', async () => {
|
|
974
|
+
const deps = mkDeps({
|
|
975
|
+
rows: [{ task_id: 500, builder_id: 6, worktree_name: 'wt-hand', title: 'x', security_sensitive: false,
|
|
976
|
+
confirmed_at: new Date().toISOString() }], // FRESH — no stale grace involved
|
|
977
|
+
mergeByBranch: { 'claude/wt-hand': { merged: false, merged_now: false, number: 500, reason: 'merge_conflict' } },
|
|
978
|
+
conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['src/bongos/db.js'] }),
|
|
979
|
+
});
|
|
980
|
+
const r = await reconciler.runSweep(deps);
|
|
981
|
+
assert.equal(deps._calls.flag.length, 1, 'flagged on THIS sweep, minutes after the conflict — not 30 later');
|
|
982
|
+
assert.equal(deps._calls.flag[0].taskId, 500);
|
|
983
|
+
assert.match(deps._calls.flag[0].reason, /^strand:unresolvable_conflict:src\/bongos\/db\.js/, 'the reason names the conflicted file so /builder-start says WHERE');
|
|
984
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 500/.test(m) && /hand-written conflict/.test(m)), 'WARN\'d with the concrete cause');
|
|
985
|
+
assert.equal(r.strandsFlagged, 1);
|
|
986
|
+
assert.equal(r.reconciled, 0, 'a conflicted PR still never ships');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('1002606 every NEW needs_rebase flag emits task.strand-flagged (the push channel)', async () => {
|
|
990
|
+
const deps = mkDeps({
|
|
991
|
+
rows: [{ task_id: 501, builder_id: 6, worktree_name: 'wt-hand2', title: 'the title', security_sensitive: true,
|
|
992
|
+
confirmed_at: new Date().toISOString() }],
|
|
993
|
+
mergeByBranch: { 'claude/wt-hand2': { merged: false, merged_now: false, number: 501, reason: 'merge_conflict' } },
|
|
994
|
+
conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['a.js'] }),
|
|
995
|
+
});
|
|
996
|
+
await reconciler.runSweep(deps);
|
|
997
|
+
const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
|
|
998
|
+
assert.equal(strandEvents.length, 1, 'exactly one push event per new flag');
|
|
999
|
+
assert.equal(strandEvents[0].payload.id, 501);
|
|
1000
|
+
assert.equal(strandEvents[0].payload.builder_id, 6);
|
|
1001
|
+
assert.equal(strandEvents[0].payload.security_sensitive, true, 'the security flag rides along so the listener can route safely');
|
|
1002
|
+
assert.match(strandEvents[0].payload.reason, /^strand:unresolvable_conflict/);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('1002606 an ALREADY-flagged strand does not re-emit (flagTaskNeedsRebase idempotence gates the event)', async () => {
|
|
1006
|
+
const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
|
|
1007
|
+
const deps = mkDeps({
|
|
1008
|
+
rows: [{ task_id: 502, builder_id: 6, worktree_name: null, published_head_sha: null, confirmed_at: old }],
|
|
1009
|
+
});
|
|
1010
|
+
deps.db.flagTaskNeedsRebase = async () => false; // already flagged on a prior sweep
|
|
1011
|
+
await reconciler.runSweep(deps);
|
|
1012
|
+
assert.equal(deps._calls.events.filter((e) => e.event === 'task.strand-flagged').length, 0,
|
|
1013
|
+
'a re-sweep of a flagged strand must not spam the channel');
|
|
1014
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 502/.test(m) && /needs_rebase already set/.test(m)),
|
|
1015
|
+
'the WARN line still fires for journal visibility');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('1002606 a PR CLOSED without merging is DEFINITIVE — flagged fresh, no 30-min grace', async () => {
|
|
1019
|
+
const deps = mkDeps({
|
|
1020
|
+
rows: [{ task_id: 503, builder_id: 8, worktree_name: 'wt-deadpr', published_head_sha: NOT_ON_MAIN,
|
|
1021
|
+
confirmed_at: new Date().toISOString() }], // FRESH
|
|
1022
|
+
statusByBranch: { 'claude/wt-deadpr': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
|
|
1023
|
+
ancestryBySha: { [NOT_ON_MAIN]: false }, // and the work is NOT on main out-of-band
|
|
1024
|
+
});
|
|
1025
|
+
const r = await reconciler.runSweep(deps);
|
|
1026
|
+
assert.deepEqual(deps._calls.flag, [{ taskId: 503, reason: 'strand:pr_closed_unmerged' }],
|
|
1027
|
+
'a dead PR will never land on its own — the builder is told now');
|
|
1028
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 503/.test(m) && /closed without merging/.test(m)));
|
|
1029
|
+
assert.equal(r.strandsFlagged, 1);
|
|
1030
|
+
assert.equal(r.staleStrands, 0, 'not an AGED strand — this is the immediate path');
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('1002606 the 1002615 out-of-band land still wins over the dead-PR flag (proof runs first)', async () => {
|
|
1034
|
+
// Same closed-unmerged PR shape, but the work IS on main — it must ship, not flag.
|
|
1035
|
+
const deps = mkDeps({
|
|
1036
|
+
rows: [{ task_id: 504, builder_id: 8, worktree_name: 'wt-oob2', published_head_sha: ON_MAIN,
|
|
1037
|
+
confirmed_at: new Date().toISOString() }],
|
|
1038
|
+
statusByBranch: { 'claude/wt-oob2': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
|
|
1039
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
1040
|
+
});
|
|
1041
|
+
const r = await reconciler.runSweep(deps);
|
|
1042
|
+
assert.equal(r.reconciled, 1, 'landed work behind a dead PR ships (1002615)');
|
|
1043
|
+
assert.equal(deps._calls.flag.length, 0, 'a landed task is never flagged');
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test('1002606 an OPEN PR still gets the grace — fresh, not flagged (CI may be legitimately running)', async () => {
|
|
1047
|
+
const deps = mkDeps({
|
|
1048
|
+
rows: [{ task_id: 505, builder_id: 8, worktree_name: 'wt-open2', confirmed_at: new Date().toISOString() }],
|
|
1049
|
+
statusByBranch: { 'claude/wt-open2': PENDING },
|
|
1050
|
+
});
|
|
1051
|
+
const r = await reconciler.runSweep(deps);
|
|
1052
|
+
assert.equal(deps._calls.flag.length, 0, 'an in-flight open PR is not a definitive strand');
|
|
1053
|
+
assert.equal(r.strandsFlagged, 0);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test('1002606 a MERGED PR mid-deploy keeps the grace (merged=true never hits the dead-PR path)', async () => {
|
|
1057
|
+
const deps = mkDeps({
|
|
1058
|
+
rows: [{ task_id: 506, builder_id: 8, worktree_name: 'wt-middeploy', published_head_sha: ON_MAIN,
|
|
1059
|
+
confirmed_at: new Date().toISOString() }],
|
|
1060
|
+
statusByBranch: { 'claude/wt-middeploy': { pr_state: 'closed', merged: true, merge_sha: 'cafe1234', deploy_status: 'pending' } },
|
|
1061
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
1062
|
+
});
|
|
1063
|
+
const r = await reconciler.runSweep(deps);
|
|
1064
|
+
assert.equal(deps._calls.flag.length, 0, 'a merge whose deploy is finishing is the NORMAL landing window');
|
|
1065
|
+
assert.equal(r.reconciled, 0, 'the deploy gate still governs (1002615 exception intact)');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test('1002606 the aged stale strand now ALSO emits task.strand-flagged', async () => {
|
|
1069
|
+
const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
|
|
1070
|
+
const deps = mkDeps({
|
|
1071
|
+
rows: [{ task_id: 507, builder_id: 9, worktree_name: null, published_head_sha: null, confirmed_at: old }],
|
|
1072
|
+
});
|
|
1073
|
+
await reconciler.runSweep(deps);
|
|
1074
|
+
const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
|
|
1075
|
+
assert.equal(strandEvents.length, 1);
|
|
1076
|
+
assert.equal(strandEvents[0].payload.id, 507);
|
|
1077
|
+
assert.match(strandEvents[0].payload.reason, /^strand:no_branch_no_tip/);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
909
1080
|
// Drain the microtask queue, then report + exit with the right code.
|
|
910
1081
|
setTimeout(() => {
|
|
911
1082
|
console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
|