@bongos/core 1.19.120 → 1.19.122

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.
@@ -55,6 +55,16 @@ function mothershipOrigin() {
55
55
  catch { return ''; }
56
56
  }
57
57
 
58
+ // The apex under which new projects get their auto-assigned <slug>.<apex> address
59
+ // (task 1002704). A dedicated branding key — NOT publicOrigin, which on a control
60
+ // plane that co-hosts a game is the GAME's domain (see mothershipOrigin above), so
61
+ // deriving from it would assign customers under the wrong zone. Blank/absent →
62
+ // no auto-assign: self-host and unconfigured instances keep creating domainless.
63
+ function instanceApex() {
64
+ try { return (api.branding().domains || {}).instanceBase || ''; }
65
+ catch { return ''; }
66
+ }
67
+
58
68
  // The provisioning mothership's product brand (e.g. "Cloud Bongos" on the control plane),
59
69
  // used to brand-qualify the auto GitHub App name so a common project slug ("demo") does
60
70
  // not collide with an existing GitHub App (task 2093). Portable — reads THIS instance's
@@ -311,6 +321,7 @@ module.exports = function provisioningRoutes() {
311
321
  target_ref: { type: 'string', maxLength: 200 },
312
322
  hosting_shape: { type: 'string', maxLength: 20 },
313
323
  domain: { type: 'string', maxLength: 253 },
324
+ no_address: { type: 'boolean' },
314
325
  tier: { type: 'string', maxLength: 40 },
315
326
  onboard_mode: { type: 'string', maxLength: 20 },
316
327
  // the declared project Type (task 1002772) — optional, closed vocabulary
@@ -337,10 +348,57 @@ module.exports = function provisioningRoutes() {
337
348
  });
338
349
  }
339
350
  try {
351
+ // A re-request must not re-litigate the address: createInstance never updates
352
+ // an existing row's domain, so for one the guards below could only misfire —
353
+ // a "Try again" on a pre-existing reserved-label row, or domain_taken fired
354
+ // against the row's own address. Birth-time rules run at birth only.
355
+ const existing = await provisioning.getInstanceBySlug(pool, slug);
356
+ // Resolve the address (task 1002704 — auto-assign, opt-out). An explicit
357
+ // domain (bring-your-own) wins; `no_address: true` is the deliberate opt-out;
358
+ // otherwise default to <slug>.<instanceBase> so an API-first caller inherits
359
+ // the same day-one address the wizard promises. An omitted domain is NOT an
360
+ // opt-out. Explicit domains are normalized + refused when malformed — the
361
+ // same bar the attach route has always applied (create inserted them raw).
362
+ let domain;
363
+ const assignedDefault = provisioning.defaultDomainForSlug(slug, instanceApex());
364
+ if (!existing) {
365
+ if (String(body.domain || '').trim()) {
366
+ domain = provisioning.normalizeHostname(body.domain);
367
+ if (!domain) {
368
+ return res.fail('bad_domain', 400, { reason: 'not a plausible hostname (letters/digits/dashes, at least two labels)' });
369
+ }
370
+ } else if (!body.no_address) {
371
+ domain = assignedDefault || undefined;
372
+ }
373
+ if (domain && provisioning.isReservedHostname(domain, instanceApex())) {
374
+ return res.fail('domain_reserved', {
375
+ status: 400,
376
+ message: `${domain} is a platform-reserved address — pick a different handle or bring your own domain.`,
377
+ });
378
+ }
379
+ // Inside the platform apex, the only address a project can hold is its own
380
+ // <slug>.<apex> — any other label under the apex is a squat on another
381
+ // handle's address space. Domains outside the apex are the real BYO path.
382
+ const apexZone = provisioning.normalizeHostname(instanceApex());
383
+ if (domain && apexZone && domain.endsWith(`.${apexZone}`) && domain !== assignedDefault) {
384
+ return res.fail('domain_reserved', {
385
+ status: 400,
386
+ message: `Inside ${apexZone}, a project's address is its own <handle>.${apexZone} — bring a domain you own instead.`,
387
+ });
388
+ }
389
+ // The same uniqueness bar as the attach route: one owner's address can
390
+ // never silently shadow another's.
391
+ if (domain) {
392
+ const holder = await provisioning.getInstanceByDomain(pool, domain);
393
+ if (holder && holder.slug !== slug) {
394
+ return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
395
+ }
396
+ }
397
+ }
340
398
  const { instance, created } = await provisioning.createInstance(pool, {
341
399
  ownerBuilderId: req.builder.id, slug,
342
400
  targetRef: body.target_ref, hostingShape: shape,
343
- domain: body.domain, tier: body.tier, onboardMode,
401
+ domain, tier: body.tier, onboardMode,
344
402
  type: body.type,
345
403
  });
346
404
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
@@ -360,7 +418,7 @@ module.exports = function provisioningRoutes() {
360
418
  if (created) {
361
419
  await provisioning.recordEvent(pool, {
362
420
  instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
363
- detail: `provision requested (shape=${shape}, mode=${onboardMode}${body.domain ? `, domain=${body.domain}` : ''})`,
421
+ detail: `provision requested (shape=${shape}, mode=${onboardMode}${domain ? `, domain=${domain} (${domain === assignedDefault ? 'assigned' : 'byo'})` : ', no address'})`,
364
422
  actor: 'api:self',
365
423
  }).catch(() => {});
366
424
  }
@@ -376,6 +434,15 @@ module.exports = function provisioningRoutes() {
376
434
  };
377
435
  }
378
436
  }
437
+ // …and a re-request cannot REMOVE one either (task 1002704's opt-out only
438
+ // applies at birth) — same honesty, opposite direction.
439
+ let noAddressIgnored;
440
+ if (!created && body.no_address && instance.domain) {
441
+ noAddressIgnored = {
442
+ current: instance.domain,
443
+ hint: "a re-request never removes an existing instance's address",
444
+ };
445
+ }
379
446
  // Same honesty for a changed Type (task 1002772): a re-request keeps the
380
447
  // original declaration, and unlike domain there is no mutation route yet —
381
448
  // say so rather than silently dropping the new answer.
@@ -392,6 +459,7 @@ module.exports = function provisioningRoutes() {
392
459
  queued: intentCreated,
393
460
  created,
394
461
  ...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
462
+ ...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
395
463
  ...(typeIgnored ? { type_ignored: typeIgnored } : {}),
396
464
  });
397
465
  } catch (err) {
@@ -497,16 +565,38 @@ module.exports = function provisioningRoutes() {
497
565
  if (!domain) {
498
566
  return res.fail('bad_domain', 400, { reason: 'a real multi-label hostname (e.g. myproject.example.com) — no scheme, path, or bare TLD' });
499
567
  }
568
+ // The runner's DNS write is an UPSERT, so an attach may not claim a
569
+ // platform-reserved name any more than a create may (task 1002704).
570
+ if (provisioning.isReservedHostname(domain, instanceApex())) {
571
+ return res.fail('domain_reserved', {
572
+ status: 400,
573
+ message: `${domain} is a platform-reserved address — bring a domain of your own instead.`,
574
+ });
575
+ }
500
576
  const inst = await provisioning.getInstanceById(pool, id);
501
577
  if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
502
578
  return res.fail('instance_not_found', 404);
503
579
  }
580
+ // Same rule as create: inside the platform apex the only address an instance
581
+ // can hold is its own <slug>.<apex> — anything else is a squat (task 1002704).
582
+ const apexZone = provisioning.normalizeHostname(instanceApex());
583
+ const assignedDefault = provisioning.defaultDomainForSlug(inst.slug, instanceApex());
584
+ if (apexZone && domain.endsWith(`.${apexZone}`) && domain !== assignedDefault) {
585
+ return res.fail('domain_reserved', {
586
+ status: 400,
587
+ message: `Inside ${apexZone}, an instance's address is its own <handle>.${apexZone} — bring a domain you own instead.`,
588
+ });
589
+ }
504
590
  const current = provisioning.normalizeHostname(inst.domain);
505
591
  if (current && current === domain) {
506
592
  return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
507
593
  message: 'This instance already has that address.' });
508
594
  }
509
- if (current) {
595
+ // A platform-ASSIGNED address may be upgraded away to a bring-your-own
596
+ // domain — that upgrade path is half the task-1002704 decision (the old
597
+ // record lingers in DNS, the idea-1000710 class). A genuine BYO address
598
+ // stays un-re-pointable, as before.
599
+ if (current && current !== assignedDefault) {
510
600
  return res.fail('domain_already_set', { status: 409,
511
601
  message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
512
602
  details: { current: inst.domain } });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.120",
3
+ "version": "1.19.122",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.120",
9
+ "version": "1.19.122",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.120",
3
+ "version": "1.19.122",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -5,6 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>{{productName}} — projects</title>
7
7
  <meta name="description" content="{{tagline}} Browse the map of what is being built, manage your own projects, and start a new one.">
8
+ <!-- the apex new projects are addressed under (server-stamped, task 1002704) —
9
+ the wizard's address preview reads this so it matches what the server will
10
+ actually assign; blank/unstamped → fall back to guessing from the host -->
11
+ <meta name="instance-base" content="{{instanceBase}}">
8
12
  <!-- Fonts: the production instance also injects these from the branding pack
9
13
  (config/branding.json theme.ui.fonts.links); kept here so the page reads
10
14
  correctly when viewed standalone. -->
@@ -2054,6 +2058,12 @@ h1[tabindex="-1"]:focus{outline:none;}
2054
2058
  dotless/local host (localhost, an IP) there is nothing sane to assign, so
2055
2059
  the step degrades to the old optional-domain form. ── */
2056
2060
  function apexBase() {
2061
+ /* the server-stamped apex wins — it is what POST /provisioning/instances
2062
+ will actually assign; the host guess below can disagree on a subdomain
2063
+ hub (e.g. get.<apex>) and would then preview an address that 400s */
2064
+ var meta = document.querySelector('meta[name="instance-base"]');
2065
+ var stamped = meta ? String(meta.content || '').toLowerCase() : '';
2066
+ if (stamped && stamped.indexOf('{{') < 0 && stamped.indexOf('.') > 0) return stamped;
2057
2067
  var h = String(location.hostname || '').toLowerCase().replace(/^www\./, '');
2058
2068
  if (!h || h.indexOf('.') < 0) return null;
2059
2069
  if (/^(localhost|127\.|\d+\.\d+\.\d+\.\d+$|\[)/.test(h)) return null;
@@ -2479,6 +2489,9 @@ h1[tabindex="-1"]:focus{outline:none;}
2479
2489
  var code = err && err.data && err.data.error && (err.data.error.code || '');
2480
2490
  if (code === 'slug_taken') return 'That handle is already taken by another project. Pick a different one.';
2481
2491
  if (code === 'bad_slug') return 'The handle didn’t pass the server’s checks — 2–40 lowercase letters, numbers, or dashes.';
2492
+ if (code === 'domain_reserved') return 'That address is reserved for the platform itself. Pick a different handle, or bring a domain you own.';
2493
+ if (code === 'domain_taken') return 'That address is already held by another project. Pick a different handle or domain.';
2494
+ if (code === 'bad_domain') return 'The domain didn’t parse as a real hostname — use something like myproject.example.com.';
2482
2495
  return 'Couldn’t file the request: ' + (err && err.message ? err.message : 'something went sideways. Try again.');
2483
2496
  }
2484
2497
 
@@ -2532,6 +2545,10 @@ h1[tabindex="-1"]:focus{outline:none;}
2532
2545
  type: state.projType || undefined,
2533
2546
  hosting_shape: 'standalone',
2534
2547
  domain: resolvedDomain() || undefined,
2548
+ /* The server now defaults an omitted domain to the assigned address
2549
+ (task 1002704), so the opt-out must travel explicitly — an absent
2550
+ domain no longer means "none". */
2551
+ no_address: state.noAddress ? true : undefined,
2535
2552
  target_ref: state.owner + '/' + state.repo,
2536
2553
  /* LOAD-BEARING for the control-plane runner, not just for the checklist we
2537
2554
  render (task 1002562): 'adopt' makes it clone + layer onto the repo's
@@ -2554,6 +2571,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2554
2571
  try {
2555
2572
  sessionStorage.setItem(CREATED_KEY, JSON.stringify({
2556
2573
  id: data.instance && data.instance.id, slug: state.slug, domain: resolvedDomain(),
2574
+ noaddr: !!state.noAddress,
2557
2575
  mode: state.mode, name: state.name, at: Date.now()
2558
2576
  }));
2559
2577
  sessionStorage.removeItem(DRAFT_KEY);
@@ -2973,6 +2991,9 @@ h1[tabindex="-1"]:focus{outline:none;}
2973
2991
  blank wizard */
2974
2992
  state.name = created.name || created.slug; state.slug = created.slug || '';
2975
2993
  state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
2994
+ /* restore the opt-out too — without it resolvedDomain() re-derives the
2995
+ assigned address and the done panel promises one that doesn't exist */
2996
+ state.noAddress = !!created.noaddr;
2976
2997
  renderDone();
2977
2998
  show(6, !!focus);
2978
2999
  startProgress(created.id);
@@ -3155,7 +3176,21 @@ h1[tabindex="-1"]:focus{outline:none;}
3155
3176
  return inst.status === 'provisioning' || inst.status === 'active';
3156
3177
  }
3157
3178
  /* client mirror of provisioning.DOMAIN_ATTACH_FROM */
3158
- function canAttachDomain(inst) { return !inst.domain && (inst.status === 'requested' || inst.status === 'error' || inst.status === 'active'); }
3179
+ /* the platform-assigned <slug>.<apex> for this instance, from the server-stamped
3180
+ apex only — no host guessing here: the upgrade door below must never show for
3181
+ an address the server would refuse to re-point (task 1002704) */
3182
+ function assignedDefaultFor(inst) {
3183
+ var meta = document.querySelector('meta[name="instance-base"]');
3184
+ var base = meta ? String(meta.content || '').toLowerCase() : '';
3185
+ if (!base || base.indexOf('{{') >= 0 || base.indexOf('.') <= 0) return null;
3186
+ return inst.slug + '.' + base;
3187
+ }
3188
+ /* domainless instances get their first address; an assigned default may be
3189
+ upgraded to a bring-your-own domain (the server allows exactly that re-point) */
3190
+ function canAttachDomain(inst) {
3191
+ return (!inst.domain || inst.domain === assignedDefaultFor(inst)) &&
3192
+ (inst.status === 'requested' || inst.status === 'error' || inst.status === 'active');
3193
+ }
3159
3194
  /* re-enqueue doors: Try again on snagged, Bring it back on torn down —
3160
3195
  both re-POST the slug (the server's PROVISION_ENQUEUE_FROM gate) */
3161
3196
  function canRetry(inst) { return inst.status === 'error' && !inst.open_intent; }
@@ -3292,7 +3327,8 @@ h1[tabindex="-1"]:focus{outline:none;}
3292
3327
  if (canAttachDomain(inst)) {
3293
3328
  body += '<div class="attach">' +
3294
3329
  '<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253">' +
3295
- '<button type="button" class="btn small" data-attach="' + iid + '">Give it an address</button></div>';
3330
+ '<button type="button" class="btn small" data-attach="' + iid + '">' +
3331
+ (inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
3296
3332
  }
3297
3333
  if (inst.status === 'error' && !inst.open_intent) {
3298
3334
  /* once a retry is queued the strip tells the story — the stale error
@@ -18,12 +18,15 @@
18
18
  // on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
19
19
 
20
20
  const fs = require('node:fs');
21
- const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
21
+ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit, repairMsysMangledPath } = require('./cli-lib');
22
22
 
23
23
 
24
24
  (async () => {
25
25
  const method = (process.argv[2] || '').toUpperCase();
26
- const urlPath = process.argv[3];
26
+ // task 1002809: Git Bash converts the leading-slash path arg into a Windows
27
+ // path under the Git install root before node sees it; undo that so builders
28
+ // don't need the MSYS_NO_PATHCONV=1 prefix.
29
+ const urlPath = repairMsysMangledPath(process.argv[3]);
27
30
  if (!method || !urlPath || !urlPath.startsWith('/')) {
28
31
  console.error("usage: node scripts/gds/api.js <METHOD> <path> [--body '<json>' | --body-file <file|->] [--quiet]");
29
32
  process.exit(2);
@@ -46,6 +49,13 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
46
49
  if (raw != null && raw.trim()) body = JSON.parse(raw);
47
50
  } catch (e) {
48
51
  console.error(`api: could not read/parse JSON body: ${e.message}`);
52
+ // task 1002809: inline --body is the usual culprit — shells (especially on
53
+ // Windows) strip or re-split the quotes before node sees them. Teach the
54
+ // durable pattern instead of leaving a bare parse error.
55
+ if (bodyFile == null && bodyInline != null) {
56
+ console.error(' Inline --body often loses its quoting through the shell. Durable pattern:');
57
+ console.error(' write the JSON to a file and pass --body-file <path>, or pipe it via --body-file -');
58
+ }
49
59
  process.exit(2);
50
60
  }
51
61
 
@@ -771,6 +771,34 @@ function parseSessionRequest(text) {
771
771
  return { timeBudgetMin, mode, parallel };
772
772
  }
773
773
 
774
+ // task 1002809: Git Bash (MSYS) rewrites a leading-slash argument into a Windows
775
+ // path under the Git install root BEFORE node sees it — `/tasks/123` arrives as
776
+ // `C:/Program Files/Git/tasks/123` — unless the builder remembers
777
+ // MSYS_NO_PATHCONV=1. Undo it here so no one needs the env prefix. EXEPATH (set
778
+ // by Git Bash) points INSIDE the install (`…\Git\bin`), while the mangled prefix
779
+ // is the install root (`…\Git`), so we strip the LONGEST ancestor of EXEPATH
780
+ // that prefixes the arg — never the bare drive root (that would "repair"
781
+ // arbitrary Windows paths). Case-insensitive, both slash styles. Pure + exported
782
+ // for tests/api_msys_path_repair.mjs; no-op off win32 or without EXEPATH.
783
+ function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform = process.platform } = {}) {
784
+ if (!raw || raw.startsWith('/')) return raw;
785
+ if (platform !== 'win32' || !exepath) return raw;
786
+ const rawFwd = String(raw).replace(/\\/g, '/');
787
+ const rawLower = rawFwd.toLowerCase();
788
+ let ancestor = String(exepath).replace(/\\/g, '/').replace(/\/+$/, '');
789
+ let best = null;
790
+ while (ancestor.includes('/')) {
791
+ // Stop before the bare drive root ("C:" after the last slice).
792
+ if (/^[a-z]:$/i.test(ancestor)) break;
793
+ if (rawLower.startsWith(ancestor.toLowerCase() + '/') && (best === null || ancestor.length > best.length)) {
794
+ best = ancestor;
795
+ }
796
+ ancestor = ancestor.slice(0, ancestor.lastIndexOf('/'));
797
+ }
798
+ if (best === null) return raw;
799
+ return rawFwd.slice(best.length);
800
+ }
801
+
774
802
  module.exports = {
775
803
  API_BASE,
776
804
  hallBase,
@@ -784,6 +812,7 @@ module.exports = {
784
812
  arg,
785
813
  hasFlag,
786
814
  stripBom,
815
+ repairMsysMangledPath,
787
816
  resolveQuiet,
788
817
  readSessionToken,
789
818
  apiCall,
@@ -521,7 +521,7 @@ async function interview({ adopt = false } = {}) {
521
521
  const wantProv = (await ask(rl, ' Provision production hosting now? (y/N)', 'N')).toLowerCase();
522
522
  if (wantProv === 'y' || wantProv === 'yes') {
523
523
  const shape = (await ask(rl, ' Hosting shape? (co-tenant = share an existing box, $0 / dedicated = its own droplet / standalone = its own repo + pinned @bongos/core, ADR 0108)', 'co-tenant')).toLowerCase();
524
- const domain = await ask(rl, ' Domain you own — bring-your-own (blank = none yet)?');
524
+ const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
525
525
  const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
526
526
  let tier = '';
527
527
  if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
@@ -780,7 +780,13 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
780
780
  provisioned = true;
781
781
  const inst = (pres.data && pres.data.instance) || {};
782
782
  const cost = Number(inst.cost_estimate_usd || 0);
783
- log(` ✓ requested provisioning of instance "${pr.slug}" (${pr.hosting_shape}${pr.domain ? `, ${pr.domain}` : ''})` +
783
+ // Report the ROW's domain, not the request's: the server may have
784
+ // auto-assigned <slug>.<instanceBase> to a domainless request (task
785
+ // 1002704) — the operator must hear the address they actually got.
786
+ const addr = inst.domain
787
+ ? `, ${inst.domain}${pr.domain ? '' : ' (auto-assigned — attach your own domain any time to replace it)'}`
788
+ : ', no address yet';
789
+ log(` ✓ requested provisioning of instance "${pr.slug}" (${pr.hosting_shape}${addr})` +
784
790
  `${cost ? ` — est. $${cost}/mo` : ''} — a provision intent is queued for the control-plane runner`);
785
791
  } else {
786
792
  const why = pres.status === 404
@@ -88,7 +88,7 @@ async function run() {
88
88
  a.repoName = await ask(rl, 'GitHub repo name?');
89
89
  a.firstAdmin = await ask(rl, 'First admin (GitHub login — the founding Archon)?', a.repoOwner);
90
90
  a.slug = await ask(rl, 'Instance slug? (DNS/db-safe, e.g. mercury)');
91
- a.domain = await ask(rl, 'Domain you own (bring-your-own; blank = decide later)?');
91
+ a.domain = await ask(rl, 'Domain you own (bring-your-own; blank = the platform assigns <slug>.<its apex> when configured)?');
92
92
 
93
93
  // Step 2 — human: the GitHub repo (create empty for greenfield; use existing for adopt)
94
94
  if (adopt) {
@@ -3122,6 +3122,40 @@ function playShipBell() {
3122
3122
  playSound(shipWav);
3123
3123
  }
3124
3124
 
3125
+ // task 1002809: normalize an API error body across BOTH wire shapes — the live
3126
+ // ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
3127
+ // `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
3128
+ // cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
3129
+ function normalizeApiError(data) {
3130
+ const errRaw = data ? data.error : undefined;
3131
+ const isEnvelope = errRaw !== null && typeof errRaw === 'object';
3132
+ return {
3133
+ code: isEnvelope ? errRaw.code : errRaw,
3134
+ message: isEnvelope ? errRaw.message : undefined,
3135
+ };
3136
+ }
3137
+
3138
+ // task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
3139
+ // already succeeded, so the server may simply have landed the task first
3140
+ // (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
3141
+ // The old check compared `data.error === 'task_not_confirmed'` — dead against
3142
+ // the envelope shape, so the already-landed SUCCESS rendered as a failure with
3143
+ // an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
3144
+ // land" gotcha). liveStatus is the task's status from a verify GET (null when
3145
+ // that GET itself failed): 'shipped' is authoritative success whatever the
3146
+ // error said; task_not_confirmed with no live status keeps the task-1049
3147
+ // benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
3148
+ // Pure + exported for tests/ship_post_land_verify.mjs.
3149
+ function postLandShipOutcome({ code, liveStatus }) {
3150
+ if (liveStatus === 'shipped') {
3151
+ return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
3152
+ }
3153
+ if (liveStatus == null && code === 'task_not_confirmed') {
3154
+ return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
3155
+ }
3156
+ return { alreadyShipped: false };
3157
+ }
3158
+
3125
3159
  // Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
3126
3160
  // deploy, flip the task to shipped, and fire the Discord broadcast (only when
3127
3161
  // the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
@@ -3146,17 +3180,26 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
3146
3180
 
3147
3181
  const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
3148
3182
  if (!ship.ok) {
3149
- // task 1049: the server's publish-status reconcile-on-read may have already
3150
- // flipped this task to shipped (a concurrent status read saw merge+deploy).
3151
- // That's a SUCCESS — don't print it as a failure.
3152
- if (ship.status === 409 && ship.data && ship.data.error === 'task_not_confirmed') {
3153
- console.log(`Task #${taskId} is now: shipped (the server reconciled it from the merged PR).`);
3183
+ // task 1049 / 1002809: the server may have already flipped this task to
3184
+ // shipped (reconcile-on-read, or the reconciler racing this call). Verify
3185
+ // the LIVE status before treating the refusal as a failure — the old
3186
+ // flat-shape string check never matched the R01 envelope, so this success
3187
+ // path was dead and every reconciled land reported as a failed ship.
3188
+ const { code, message } = normalizeApiError(ship.data);
3189
+ let liveStatus = null;
3190
+ try {
3191
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
3192
+ if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
3193
+ } catch (_) { /* verify is best-effort; fall back to the code-based call */ }
3194
+ const outcome = postLandShipOutcome({ code, liveStatus });
3195
+ if (outcome.alreadyShipped) {
3196
+ console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
3154
3197
  } else {
3155
- console.error(` /tasks/${taskId}/ship failed:`, ship.data);
3198
+ const e = message || code || `HTTP ${ship.status}`;
3199
+ console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
3156
3200
  // The merge itself succeeded — only the status flip failed, so a re-run
3157
3201
  // resumes cleanly (task 941). Report it rather than letting the card claim
3158
3202
  // the land is in progress.
3159
- const e = (ship.data && (ship.data.message || ship.data.error)) || `HTTP ${ship.status}`;
3160
3203
  return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
3161
3204
  }
3162
3205
  } else {
@@ -4418,6 +4461,8 @@ module.exports = {
4418
4461
  // task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
4419
4462
  // 'pending' (success), not failure. Exported so the semantics are unit-tested.
4420
4463
  classifyLandOutcome,
4464
+ normalizeApiError,
4465
+ postLandShipOutcome,
4421
4466
  // task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
4422
4467
  // dead-end strand at confirmed), enable-success watches GitHub. Exported so the
4423
4468
  // regression test locks in that a worktree enable-failure still waits for the land.