@bongos/core 1.21.60 → 1.21.62
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 +59 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +58 -3
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +10 -3
- package/docs/copy-registry.json +67 -4
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-readings.json +222 -215
- package/modules/hall-ui/public/project-settings.css +5 -0
- package/modules/hall-ui/public/project-settings.html +12 -0
- package/modules/hall-ui/public/project-settings.js +20 -0
- package/modules/hall-ui/records/project-danger-zone.md +10 -0
- package/modules/lifecycle/merge-lock.js +53 -9
- package/modules/lifecycle/ship-preflight.js +40 -22
- package/modules/public-landing/public/projects.html +30 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/ship-grade.js +13 -6
- package/scripts/gds/ship-merge-gate.js +26 -0
- package/scripts/gds/ship-merge.js +12 -3
- package/scripts/gds/ship-state.js +42 -5
- package/src/bongos/routes/core-update.js +33 -0
- package/src/module-api.js +1 -1
- package/tests/merge_lock.mjs +58 -0
- package/tests/project_manage_link.mjs +111 -0
- package/tests/ship_merge_gate.mjs +63 -0
- package/tests/ship_preflight.mjs +46 -1
- package/tests/ship_vendored_instance.mjs +259 -0
|
@@ -214,3 +214,8 @@
|
|
|
214
214
|
the 24px floor: the prose-link recipe buys the height with padding and hands it back
|
|
215
215
|
with an equal negative margin, so the line does not re-space. */
|
|
216
216
|
.dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
|
|
217
|
+
|
|
218
|
+
/* The danger zone (task 1004570) — two links out to the hosting platform, each with
|
|
219
|
+
one sentence saying what it does. Plain list, the lede's measure. */
|
|
220
|
+
.ps-danger__list { margin: 10px 0 0; padding-left: 18px; line-height: 1.6; max-width: var(--measure); }
|
|
221
|
+
.ps-danger__list li + li { margin-top: 8px; }
|
|
@@ -49,6 +49,18 @@
|
|
|
49
49
|
thing this page exists to end. -->
|
|
50
50
|
<div id="ps-rows"><span class="kit-skel"></span></div>
|
|
51
51
|
</section>
|
|
52
|
+
<!-- Danger zone (task 1004570): where this project's OWNER takes it offline or
|
|
53
|
+
disconnects it. Both controls live on the platform that hosts the project,
|
|
54
|
+
so this only links there. Hidden unless GET /project/manage-link answers
|
|
55
|
+
with a link — an Archon on a hosted project; never on a self-hosted one. -->
|
|
56
|
+
<section class="scroll" id="ps-danger" aria-labelledby="ps-danger-h" hidden>
|
|
57
|
+
<h2 class="scroll__h" id="ps-danger-h">Danger zone</h2>
|
|
58
|
+
<p class="scroll__lede"><span id="ps-danger-lede"></span> <a id="ps-danger-manage" href="#"></a></p>
|
|
59
|
+
<ul class="ps-danger__list">
|
|
60
|
+
<li><a id="ps-danger-offline" href="#">Take it offline</a> — stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.</li>
|
|
61
|
+
<li><a id="ps-danger-disconnect" href="#">Disconnect from Bongos entirely</a> — erases the project’s server, its database and every backup, and releases its name and web address. Your repository on GitHub is kept. This cannot be undone.</li>
|
|
62
|
+
</ul>
|
|
63
|
+
</section>
|
|
52
64
|
</main>
|
|
53
65
|
|
|
54
66
|
<footer class="app-foot" id="app-foot"></footer>
|
|
@@ -245,5 +245,25 @@
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// The danger zone (task 1004570). The server answers only an Archon — on a hosted
|
|
249
|
+
// project, its owner — and answers { link: null } where there is no hosting platform
|
|
250
|
+
// to link to, so every other outcome (refused, failed, self-hosted) leaves the section
|
|
251
|
+
// hidden: a missing link, never a dead one. The act itself stays on the platform.
|
|
252
|
+
async function loadDanger() {
|
|
253
|
+
let link = null;
|
|
254
|
+
try {
|
|
255
|
+
const res = await api.request('GET', `${API}/project/manage-link`);
|
|
256
|
+
link = res.ok && res.data ? res.data.link : null;
|
|
257
|
+
} catch (_) { link = null; }
|
|
258
|
+
if (!link || !link.offline_url || !link.disconnect_url) return;
|
|
259
|
+
$('ps-danger-lede').textContent = `This project is hosted on ${link.hub_host}, and that is where it is taken offline or removed. Both links below open this project’s page there.`;
|
|
260
|
+
$('ps-danger-manage').textContent = `Manage this project on ${link.hub_host}`;
|
|
261
|
+
$('ps-danger-manage').href = link.manage_url;
|
|
262
|
+
$('ps-danger-offline').href = link.offline_url;
|
|
263
|
+
$('ps-danger-disconnect').href = link.disconnect_url;
|
|
264
|
+
$('ps-danger').hidden = false;
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
load();
|
|
268
|
+
loadDanger();
|
|
249
269
|
})();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The danger zone on /project-settings — a hosted project's owner reaches Take-offline and Delete (task 1004570)
|
|
2
|
+
|
|
3
|
+
The owner of a hosted project could not find how to delete it: both controls live on the hub's Projects page, and nothing in the project's own hall pointed there. What this settled:
|
|
4
|
+
|
|
5
|
+
- **Link-out only.** The section links to the hub's page for this project, scrolled to the `#mDelete` / `#mDisconnect` cards. The typed-name confirm and the intent queue stay on the hub (ADR 0290); the hall gains no second delete path.
|
|
6
|
+
- **The server decides who sees it.** `GET /project/manage-link` is Archon-only (on a hosted project the owner is its Archon), and answers `{ link: null }` when the instance has no hub to link to — a self-hosted instance, or the hub itself. The section is `hidden` in the markup and shown only for a real link, so every other outcome is a missing section, never a dead link.
|
|
7
|
+
- **The link names the project by slug.** The hall knows its hub origin and client id (which is its slug, ADR 0246) from its sign-in configuration, never a hardcoded domain. The hub's Projects page resolves `?view=manage&project=<slug>` against the visitor's OWN projects; a slug that is not theirs opens their list, not someone else's project. The slug survives a sign-in detour.
|
|
8
|
+
- **The two sentences track the hub's folds.** `tests/project_manage_link.mjs` holds the hall's claims (repository kept, cannot be undone, can bring it back) against the hub's own fold copy.
|
|
9
|
+
|
|
10
|
+
Tests: `tests/project_manage_link.mjs` (C1 owner link, C2 non-owner refused by the real gate, C3 self-hosted answers none). Harness fixture: `project__manage-link.json`.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ship-vs-merge-mode, and merge-mode-vs-merge-mode are all serialized.
|
|
11
11
|
// (V3.R50 / #269, criterion C6.)
|
|
12
12
|
//
|
|
13
|
-
// The lock is a plain file at <
|
|
13
|
+
// The lock is a plain file at <common git dir>/otb-automerge.lock (see lockPath). We don't use
|
|
14
14
|
// flock(2): ships and merge runs happen on macOS, where flock over an
|
|
15
15
|
// iCloud-synced filesystem is unreliable, and the holder may be a short-lived
|
|
16
16
|
// CLI process (the manual /merge-mode case has no long-lived owner). The file
|
|
@@ -43,14 +43,54 @@
|
|
|
43
43
|
|
|
44
44
|
const fs = require('node:fs');
|
|
45
45
|
const path = require('node:path');
|
|
46
|
-
const { execSync } = require('node:child_process');
|
|
46
|
+
const { execSync, execFileSync } = require('node:child_process');
|
|
47
47
|
|
|
48
48
|
const AUTO_STALE_MS = 10 * 60 * 1000; // crashed automated ship
|
|
49
49
|
const MANUAL_STALE_MS = 30 * 60 * 1000; // operator walked away mid-merge-mode
|
|
50
50
|
const INDEX_LOCK_GRACE_MS = 60 * 1000; // a git index.lock younger than this may be a LIVE op
|
|
51
51
|
|
|
52
|
+
// lockPath — the lockfile lives in the repo's COMMON git dir (task 1004584).
|
|
53
|
+
// When `main` is checked out in a LINKED worktree, <root>/.git is a FILE (a
|
|
54
|
+
// `gitdir:` pointer), so the old <root>/.git/otb-automerge.lock was an ENOENT
|
|
55
|
+
// on every acquire. The common dir is the same folder from every worktree, so
|
|
56
|
+
// a ship from any of them and the merge-lock CLI all contend for ONE file.
|
|
57
|
+
// A primary checkout (.git is a directory) short-circuits without spawning git;
|
|
58
|
+
// when git cannot answer, the historical path is kept.
|
|
52
59
|
function lockPath(repoRoot) {
|
|
53
|
-
return path.join(repoRoot, '
|
|
60
|
+
return path.join(commonGitDir(repoRoot), 'otb-automerge.lock');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isDir(p) {
|
|
64
|
+
try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ask git for a path inside the repo's git dirs, absolute. `args` is the
|
|
68
|
+
// rev-parse tail (['--git-common-dir'] or ['--git-path', 'index.lock']). Null
|
|
69
|
+
// when the root is not a repo or git is unavailable.
|
|
70
|
+
function gitRevParsePath(repoRoot, args) {
|
|
71
|
+
try {
|
|
72
|
+
const out = execFileSync('git', ['rev-parse', ...args], {
|
|
73
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
74
|
+
}).trim();
|
|
75
|
+
if (!out) return null;
|
|
76
|
+
return path.isAbsolute(out) ? out : path.resolve(repoRoot, out);
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function commonGitDir(repoRoot) {
|
|
83
|
+
const dotGit = path.join(repoRoot, '.git');
|
|
84
|
+
if (isDir(dotGit)) return dotGit;
|
|
85
|
+
return gitRevParsePath(repoRoot, ['--git-common-dir']) || dotGit;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The worktree's OWN index.lock — per-worktree, so a linked main keeps it in
|
|
89
|
+
// .git/worktrees/<name>/, not under the common dir.
|
|
90
|
+
function indexLockPath(repoRoot) {
|
|
91
|
+
const dotGit = path.join(repoRoot, '.git');
|
|
92
|
+
if (isDir(dotGit)) return path.join(dotGit, 'index.lock');
|
|
93
|
+
return gitRevParsePath(repoRoot, ['--git-path', 'index.lock']) || path.join(dotGit, 'index.lock');
|
|
54
94
|
}
|
|
55
95
|
|
|
56
96
|
// Synchronous sleep without spawning a process (ship.js is execSync-style and
|
|
@@ -76,9 +116,11 @@ function pidAlive(pid) {
|
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
// Parse the lockfile into { pid, ts, mode } or null if absent/unreadable.
|
|
79
|
-
|
|
119
|
+
// `file` lets a caller that already resolved lockPath skip re-resolving it (for
|
|
120
|
+
// a linked worktree that is a git spawn per call).
|
|
121
|
+
function readLock(repoRoot, file = lockPath(repoRoot)) {
|
|
80
122
|
try {
|
|
81
|
-
const [p, t, m] = fs.readFileSync(
|
|
123
|
+
const [p, t, m] = fs.readFileSync(file, 'utf8').split('\n');
|
|
82
124
|
const pid = parseInt(p, 10);
|
|
83
125
|
const ts = parseInt(t, 10) || 0;
|
|
84
126
|
const mode = m && m.trim() === 'manual' ? 'manual' : 'auto';
|
|
@@ -118,7 +160,7 @@ function recoverWorktree(repoRoot, { now = Date.now, runner = execSync } = {}) {
|
|
|
118
160
|
// live git process could still own it. A fresh lock means a live op
|
|
119
161
|
// (possibly a holder that just acquired after us); never yank that.
|
|
120
162
|
try {
|
|
121
|
-
const lk =
|
|
163
|
+
const lk = indexLockPath(repoRoot);
|
|
122
164
|
const st = fs.statSync(lk); // throws ENOENT when absent → nothing to clear
|
|
123
165
|
if (now() - st.mtimeMs > INDEX_LOCK_GRACE_MS) fs.unlinkSync(lk);
|
|
124
166
|
} catch (_) {
|
|
@@ -176,7 +218,7 @@ function acquire(repoRoot, opts = {}) {
|
|
|
176
218
|
return true;
|
|
177
219
|
} catch (e) {
|
|
178
220
|
if (e.code !== 'EEXIST') throw e;
|
|
179
|
-
const rec = readLock(repoRoot);
|
|
221
|
+
const rec = readLock(repoRoot, p); // p resolved once, before the loop
|
|
180
222
|
if (isStale(rec, now())) {
|
|
181
223
|
let reclaimed = false;
|
|
182
224
|
try {
|
|
@@ -206,10 +248,11 @@ function acquire(repoRoot, opts = {}) {
|
|
|
206
248
|
function release(repoRoot, opts = {}) {
|
|
207
249
|
const { pid = process.pid, force = false } = opts;
|
|
208
250
|
try {
|
|
209
|
-
const
|
|
251
|
+
const file = lockPath(repoRoot);
|
|
252
|
+
const rec = readLock(repoRoot, file);
|
|
210
253
|
if (!rec) return;
|
|
211
254
|
if (force || rec.mode === 'manual' || rec.pid === pid) {
|
|
212
|
-
fs.unlinkSync(
|
|
255
|
+
fs.unlinkSync(file);
|
|
213
256
|
}
|
|
214
257
|
} catch (_) {
|
|
215
258
|
/* already gone */
|
|
@@ -251,6 +294,7 @@ module.exports = {
|
|
|
251
294
|
MANUAL_STALE_MS,
|
|
252
295
|
INDEX_LOCK_GRACE_MS,
|
|
253
296
|
lockPath,
|
|
297
|
+
indexLockPath,
|
|
254
298
|
pidAlive,
|
|
255
299
|
readLock,
|
|
256
300
|
isStale,
|
|
@@ -245,6 +245,31 @@ function cleanupMacFinderDupRefs(cwd) {
|
|
|
245
245
|
return deleted;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// worktreeBlocks — `git worktree list --porcelain` as [{ dir, branch, bare }], in
|
|
249
|
+
// git's order (the primary checkout first). Null when git can't answer. The one
|
|
250
|
+
// parser behind findMainWorktree and findPrimaryWorktree.
|
|
251
|
+
function worktreeBlocks(cwd) {
|
|
252
|
+
try {
|
|
253
|
+
const out = execSync('git worktree list --porcelain', {
|
|
254
|
+
cwd,
|
|
255
|
+
encoding: 'utf8',
|
|
256
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
257
|
+
});
|
|
258
|
+
return out.split('\n\n').filter(Boolean).map((block) => {
|
|
259
|
+
const wt = { dir: null, branch: null, bare: false };
|
|
260
|
+
for (const raw of block.split('\n')) {
|
|
261
|
+
const line = raw.trim();
|
|
262
|
+
if (line.startsWith('worktree ')) wt.dir = line.slice('worktree '.length);
|
|
263
|
+
else if (line.startsWith('branch refs/heads/')) wt.branch = line.slice('branch refs/heads/'.length);
|
|
264
|
+
else if (line === 'bare') wt.bare = true;
|
|
265
|
+
}
|
|
266
|
+
return wt;
|
|
267
|
+
});
|
|
268
|
+
} catch (_) {
|
|
269
|
+
return null; // not a git repo, or git not available — caller falls back
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
248
273
|
function findMainWorktree(cwd) {
|
|
249
274
|
// The auto-merge in ship.js runs git on the worktree that has `main`
|
|
250
275
|
// checked out. Feature work happens in linked worktrees under
|
|
@@ -260,28 +285,20 @@ function findMainWorktree(cwd) {
|
|
|
260
285
|
// branch refs/heads/claude/feature
|
|
261
286
|
//
|
|
262
287
|
// Returns the worktree path whose branch is `main`, or null if none.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (branch === 'main' && dir) return dir;
|
|
280
|
-
}
|
|
281
|
-
} catch (_) {
|
|
282
|
-
// Not a git repo or git not available — caller will fall back.
|
|
283
|
-
}
|
|
284
|
-
return null;
|
|
288
|
+
const main = (worktreeBlocks(cwd) || []).find((wt) => wt.branch === 'main' && wt.dir);
|
|
289
|
+
return main ? main.dir : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// findPrimaryWorktree — the repo's primary checkout (the first block of
|
|
293
|
+
// `git worktree list --porcelain`), whatever branch it is on. The fallback merge
|
|
294
|
+
// target when NO worktree has `main` checked out (task 1004584): it is still the
|
|
295
|
+
// instance's own checkout, so ship can say "this is on X, not main" instead of
|
|
296
|
+
// falling back to the core package folder — which in a vendored instance is
|
|
297
|
+
// node_modules/@bongos/core, not a repo at all. Null for a bare primary or when
|
|
298
|
+
// git can't answer.
|
|
299
|
+
function findPrimaryWorktree(cwd) {
|
|
300
|
+
const first = (worktreeBlocks(cwd) || [])[0];
|
|
301
|
+
return first && !first.bare && first.dir ? first.dir : null;
|
|
285
302
|
}
|
|
286
303
|
|
|
287
304
|
// task 932: post-ship working-tree CLOSE-OUT plan (pure). The mirror image of
|
|
@@ -308,5 +325,6 @@ module.exports = {
|
|
|
308
325
|
cleanupMacFinderDupRefs,
|
|
309
326
|
gitDirFor,
|
|
310
327
|
findMainWorktree,
|
|
328
|
+
findPrimaryWorktree,
|
|
311
329
|
closeoutPlan,
|
|
312
330
|
};
|
|
@@ -2477,6 +2477,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2477
2477
|
the ?view=manage&id=N deep link, read by loadManage() */
|
|
2478
2478
|
var manageId = null;
|
|
2479
2479
|
var manageInst = null;
|
|
2480
|
+
/* a project's own hall links here by SLUG (task 1004570): it knows its slug, not
|
|
2481
|
+
this page's instance id. Held until the visitor's own projects load, then
|
|
2482
|
+
resolved against THOSE only — a slug that is not yours opens nothing. The
|
|
2483
|
+
section (#mDelete / #mDisconnect) is held too, because setView rewrites the
|
|
2484
|
+
URL and drops the hash before the manage view has painted. */
|
|
2485
|
+
var handedProject = null;
|
|
2486
|
+
var handedSection = null;
|
|
2480
2487
|
|
|
2481
2488
|
function viewFromUrl() {
|
|
2482
2489
|
var v;
|
|
@@ -2486,6 +2493,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2486
2493
|
var id = null;
|
|
2487
2494
|
try { id = new URLSearchParams(location.search).get('id'); } catch (e) {}
|
|
2488
2495
|
if (id) { manageId = String(id); return 'manage'; }
|
|
2496
|
+
try { handedProject = new URLSearchParams(location.search).get('project') || null; } catch (e) {}
|
|
2497
|
+
if (handedProject && /^#m(Delete|Disconnect)$/.test(location.hash || '')) handedSection = location.hash.slice(1);
|
|
2489
2498
|
return 'mine';
|
|
2490
2499
|
}
|
|
2491
2500
|
return VIEWS[v] ? v : 'map';
|
|
@@ -2526,8 +2535,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2526
2535
|
sign-in door on the hub's own pages carries the repo scope (task 1004549,
|
|
2527
2536
|
ADR 0364): it is asked once, at sign-in, and held for the whole session, so
|
|
2528
2537
|
no flow ever stops halfway to ask for it */
|
|
2529
|
-
|
|
2530
|
-
|
|
2538
|
+
/* a hand-off that is still waiting on sign-in keeps its slug through the door */
|
|
2539
|
+
var backPart = handedProject ? encodeURIComponent('?view=manage&project=' + handedProject)
|
|
2540
|
+
: '%3Fview%3D' + v + idPart.replace('&', '%26').replace('=', '%3D');
|
|
2541
|
+
$('signinTop').href = '/api/bongos/auth/web/start?return=/projects' + backPart + '&repos=1';
|
|
2531
2542
|
/* the mine view is the only one that polls; it re-reads on entry so a
|
|
2532
2543
|
card that changed while you were on the map is not stale — including
|
|
2533
2544
|
the membership + invitations lists (task 1003774), the same re-read
|
|
@@ -3324,6 +3335,16 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3324
3335
|
opens that project's own manage view — the per-project page the
|
|
3325
3336
|
manage-wave tasks fill in. It replaced the scroll-anchor-into-the-list
|
|
3326
3337
|
this function used to do. ── */
|
|
3338
|
+
/* the hall's hand-off (task 1004570): the slug becomes one of YOUR projects or
|
|
3339
|
+
nothing — this page stays on your list rather than guessing */
|
|
3340
|
+
function openHandedProject() {
|
|
3341
|
+
if (!handedProject) return;
|
|
3342
|
+
var slug = handedProject;
|
|
3343
|
+
handedProject = null;
|
|
3344
|
+
var inst = ownedInstances.filter(function (i) { return i && i.slug === slug; })[0];
|
|
3345
|
+
if (inst) goToManage(inst.id);
|
|
3346
|
+
else handedSection = null;
|
|
3347
|
+
}
|
|
3327
3348
|
function goToManage(instId) {
|
|
3328
3349
|
manageId = String(instId || '');
|
|
3329
3350
|
manageInst = null;
|
|
@@ -8609,6 +8630,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8609
8630
|
var inst = data.instance || {};
|
|
8610
8631
|
inst.open_intent = data.open_intent || null;
|
|
8611
8632
|
renderManage(inst);
|
|
8633
|
+
/* the hall's hand-off named a card — land on it once the page has its content */
|
|
8634
|
+
if (handedSection) {
|
|
8635
|
+
var target = $(handedSection);
|
|
8636
|
+
handedSection = null;
|
|
8637
|
+
if (target && target.scrollIntoView) target.scrollIntoView({ block: 'start' });
|
|
8638
|
+
}
|
|
8612
8639
|
/* the SECOND, GitHub-backed read — fired only here (entry + tab
|
|
8613
8640
|
return), never from renderManage, which the fetch-free tick calls */
|
|
8614
8641
|
if (!keepRepo) loadRepoVisibility(inst);
|
|
@@ -10841,7 +10868,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
10841
10868
|
$('gate').hidden = true;
|
|
10842
10869
|
$('mine').hidden = false;
|
|
10843
10870
|
load();
|
|
10844
|
-
return Promise.all([loadMemberships(), loadOwnedInstances(), loadInvites()]);
|
|
10871
|
+
return Promise.all([loadMemberships(), loadOwnedInstances(), loadInvites()]).then(openHandedProject);
|
|
10845
10872
|
})
|
|
10846
10873
|
.catch(function (err) {
|
|
10847
10874
|
me = null;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.62",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.62",
|
|
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.21.
|
|
3
|
+
"version": "1.21.62",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9132,5 +9132,21 @@
|
|
|
9132
9132
|
"id": "1004553",
|
|
9133
9133
|
"text": "Builders can now save their filter settings on the Goals and Tasks pages as named views, pick one to open the page with, and find them again on any device. Each builder's views are private to them."
|
|
9134
9134
|
}
|
|
9135
|
+
],
|
|
9136
|
+
"1.21.61": [
|
|
9137
|
+
{
|
|
9138
|
+
"id": "1004570",
|
|
9139
|
+
"text": "A project owner now sees a Danger zone in their project's settings, with links straight to the place where they can take the project offline or delete it. Nobody else sees it, and self-hosted projects don't show it."
|
|
9140
|
+
}
|
|
9141
|
+
],
|
|
9142
|
+
"1.21.62": [
|
|
9143
|
+
{
|
|
9144
|
+
"id": "1003461",
|
|
9145
|
+
"text": "Decided how planning works in the hall: simple forms for creating tasks, goals and criteria, while version planning stays a guided Claude session. A goal made in the hall now comes with its own 'spec this goal' task that the r"
|
|
9146
|
+
},
|
|
9147
|
+
{
|
|
9148
|
+
"id": "1004584",
|
|
9149
|
+
"text": "Shipping finished tasks now works for projects that install Bongos as a package, so their work lands instead of getting stuck halfway."
|
|
9150
|
+
}
|
|
9135
9151
|
]
|
|
9136
9152
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const crypto = require('node:crypto');
|
|
12
|
+
const path = require('node:path');
|
|
12
13
|
const { spawn } = require('node:child_process');
|
|
13
14
|
const { cliClient } = require('./cli-lib');
|
|
14
15
|
const grader = require('../../modules/grading/grader');
|
|
@@ -22,7 +23,10 @@ const grader = require('../../modules/grading/grader');
|
|
|
22
23
|
const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
|
|
23
24
|
const { resolveBuilderModel } = require('./ship-memory-sync.js');
|
|
24
25
|
const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
|
|
25
|
-
const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
|
|
26
|
+
const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, CORE_ROOT, REPO_ROOT, noteServerReward } = require('./ship-state.js');
|
|
27
|
+
|
|
28
|
+
// The core's smoke suite, by absolute path from the package dir (task 1004584).
|
|
29
|
+
const SMOKE_SCRIPT = path.join(CORE_ROOT, 'scripts', 'gds', 'smoke-gds.js');
|
|
26
30
|
|
|
27
31
|
// ---------- Step 1+2: local verification ----------
|
|
28
32
|
|
|
@@ -380,10 +384,13 @@ async function runNoArtifactGrader({ task, valueSummary, notes, runOpts: extraOp
|
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
function runSmoke() {
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
+
// The script is addressed from CORE_ROOT, never cwd-relative: in a vendored
|
|
388
|
+
// instance it lives under node_modules/@bongos/core, not under the instance
|
|
389
|
+
// checkout, and the relative form died MODULE_NOT_FOUND after a clean merge —
|
|
390
|
+
// which then reset main and stranded the task at confirmed (task 1004584).
|
|
391
|
+
// cwd stays the instance checkout so instance config resolves as before; the
|
|
392
|
+
// JSON.stringify quoting survives spaces in either path.
|
|
393
|
+
const okPms = shellOk(`"${process.execPath}" ${JSON.stringify(SMOKE_SCRIPT)}`, { cwd: REPO_ROOT });
|
|
387
394
|
return { ran: true, clean: okPms };
|
|
388
395
|
}
|
|
389
396
|
|
|
@@ -394,7 +401,7 @@ function runSmoke() {
|
|
|
394
401
|
// post-merge smoke call keeps the sync form (nothing overlaps there).
|
|
395
402
|
function runSmokeAsync() {
|
|
396
403
|
return new Promise((resolve) => {
|
|
397
|
-
const child = spawn(process.execPath, [
|
|
404
|
+
const child = spawn(process.execPath, [SMOKE_SCRIPT], {
|
|
398
405
|
cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
|
|
399
406
|
});
|
|
400
407
|
child.on('error', () => resolve({ ran: true, clean: false }));
|
|
@@ -168,7 +168,32 @@ function removeProbe(repoRoot, dir) {
|
|
|
168
168
|
// tip as it stood BEFORE the branch was merged in (in ship-merge.js that is
|
|
169
169
|
// origin/main, since the ff-only step immediately above it guarantees the two
|
|
170
170
|
// are the same commit).
|
|
171
|
+
// gateApplies — does this repo carry the fitness gate at all? A VENDORED
|
|
172
|
+
// instance (core under node_modules/@bongos/core) has no scripts/gds/fitness.js
|
|
173
|
+
// of its own — fitness is the core's architecture gate, and the core ran it in
|
|
174
|
+
// its own CI before it was published. Without this the gate read the missing
|
|
175
|
+
// file as 'unmeasurable' on BOTH sides and blocked every vendored land
|
|
176
|
+
// (task 1004584). It only stands down when the pre-merge base lacks the file
|
|
177
|
+
// too, so a branch that DELETES fitness.js from the core is still measured
|
|
178
|
+
// (and blocked as unmeasurable) rather than waved through.
|
|
179
|
+
function gateApplies(repoRoot, baseSha) {
|
|
180
|
+
if (fs.existsSync(path.join(repoRoot, 'scripts', 'gds', 'fitness.js'))) return true;
|
|
181
|
+
if (!baseSha) return true;
|
|
182
|
+
try {
|
|
183
|
+
execFileSync('git', ['cat-file', '-e', `${baseSha}:scripts/gds/fitness.js`], {
|
|
184
|
+
cwd: repoRoot, stdio: 'ignore', shell: false,
|
|
185
|
+
});
|
|
186
|
+
return true; // the base had it — this merge removed it; measure (and block)
|
|
187
|
+
} catch (_) {
|
|
188
|
+
return false; // neither side carries the gate — nothing to run
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
|
|
193
|
+
if (!gateApplies(repoRoot, baseSha)) {
|
|
194
|
+
log(' fitness: this repo carries no scripts/gds/fitness.js (a vendored instance) — gate not applicable');
|
|
195
|
+
return { block: false, reason: 'not-applicable' };
|
|
196
|
+
}
|
|
172
197
|
log(' fitness against post-merge state...');
|
|
173
198
|
const mergeClean = runFitnessAt(repoRoot);
|
|
174
199
|
const baseClean = mergeClean ? true : probeBaseClean(repoRoot, baseSha);
|
|
@@ -179,6 +204,7 @@ function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
|
|
|
179
204
|
|
|
180
205
|
module.exports = {
|
|
181
206
|
fitnessOutcome,
|
|
207
|
+
gateApplies,
|
|
182
208
|
gateMergeResult,
|
|
183
209
|
gateMessage,
|
|
184
210
|
mergeGateVerdict,
|
|
@@ -31,7 +31,7 @@ const { runSmoke } = require('./ship-grade.js');
|
|
|
31
31
|
const { gateMergeResult } = require('./ship-merge-gate.js');
|
|
32
32
|
const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
|
|
33
33
|
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
34
|
-
const { REPO_ROOT } = require('./ship-state.js');
|
|
34
|
+
const { MAIN_CHECKED_OUT, REPO_ROOT } = require('./ship-state.js');
|
|
35
35
|
const { envPrefix } = require('../../src/instance-config');
|
|
36
36
|
|
|
37
37
|
// zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
|
|
@@ -265,9 +265,14 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
265
265
|
// — the branch lands fine via a PR. Tagged PR-recoverable so autoMerge falls
|
|
266
266
|
// back instead of parking the task at 'confirmed' unpaid. The reason +
|
|
267
267
|
// nextStep are kept for the case where the fallback itself bails.
|
|
268
|
+
// task 1004584: when NO worktree holds main at all, say exactly that and
|
|
269
|
+
// how to fix it — REPO_ROOT is then the primary checkout by fallback, and
|
|
270
|
+
// "work in your own worktree" is advice the builder already followed.
|
|
268
271
|
return landBailed(
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
MAIN_CHECKED_OUT
|
|
273
|
+
? `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`
|
|
274
|
+
: `no worktree of this repo has main checked out (the main checkout ${REPO_ROOT} is on ${onMain}) — run \`git checkout main\` there, then re-ship`,
|
|
275
|
+
MAIN_CHECKED_OUT ? '/merge-mode' : `git -C "${REPO_ROOT}" checkout main && node scripts/gds/ship.js ${taskId}`,
|
|
271
276
|
LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
|
|
272
277
|
);
|
|
273
278
|
}
|
|
@@ -448,6 +453,10 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
448
453
|
|
|
449
454
|
module.exports = {
|
|
450
455
|
autoMerge,
|
|
456
|
+
// task 1004584: the local merge leg on its own, so the vendored-instance
|
|
457
|
+
// regression test can read a bail's reason without the PR fallback (which
|
|
458
|
+
// needs an authed gh) swallowing it.
|
|
459
|
+
mergeLocally,
|
|
451
460
|
// task 1004322: the held page tweak's publish-only path.
|
|
452
461
|
publishHeld,
|
|
453
462
|
// task 1002572 (PART 2): the pure recovery command for a ship run from main —
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
'use strict';
|
|
19
19
|
|
|
20
20
|
const path = require('node:path');
|
|
21
|
-
const { findMainWorktree } = require('../../modules/lifecycle/ship-preflight');
|
|
21
|
+
const { findMainWorktree, findPrimaryWorktree } = require('../../modules/lifecycle/ship-preflight');
|
|
22
22
|
|
|
23
23
|
// The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
|
|
24
24
|
// task 1002469). Cached module-level because the credits print + the completion
|
|
@@ -43,10 +43,43 @@ function getServerReward() {
|
|
|
43
43
|
const GRADER_DIFF_CHAR_CAP = 250_000;
|
|
44
44
|
const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
46
|
+
// TWO ROOTS, not one (task 1004584). In the core's own repo they are the same
|
|
47
|
+
// folder, which is how a single REPO_ROOT served both jobs for so long. In a
|
|
48
|
+
// VENDORED instance — the core installed as node_modules/@bongos/core — they are
|
|
49
|
+
// not, and conflating them broke every land (mercury, core 1.21.20):
|
|
50
|
+
// CORE_ROOT — where the core's own scripts live (the package dir). Every
|
|
51
|
+
// core script ship spawns (smoke-gds.js, …) is addressed from
|
|
52
|
+
// here, never by a cwd-relative path.
|
|
53
|
+
// INSTANCE_ROOT — the instance repo's checkout that auto-merge runs in: the
|
|
54
|
+
// worktree with `main` checked out (primary OR linked). With no
|
|
55
|
+
// `main` anywhere it is the primary checkout, so the merge leg
|
|
56
|
+
// bails with "on X, not main" — never the package dir, which is
|
|
57
|
+
// not a repo and turned the bail into an ENOENT on the lock.
|
|
58
|
+
// Git is asked from __dirname first (unchanged for the core repo), then from the
|
|
59
|
+
// process cwd (the feature worktree ship is run from) as a backstop.
|
|
60
|
+
const CORE_ROOT = path.resolve(__dirname, '..', '..');
|
|
61
|
+
|
|
62
|
+
// resolveRoots — the pure resolution, with git injected so the vendored layouts
|
|
63
|
+
// are unit-testable. `mainCheckedOut` is false when no worktree holds `main`;
|
|
64
|
+
// ship-merge names that case in its bail instead of a generic "on X".
|
|
65
|
+
function resolveRoots({
|
|
66
|
+
dirname = __dirname,
|
|
67
|
+
cwd = process.cwd(),
|
|
68
|
+
findMain = findMainWorktree,
|
|
69
|
+
findPrimary = findPrimaryWorktree,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const core = path.resolve(dirname, '..', '..');
|
|
72
|
+
const main = findMain(dirname) || findMain(cwd);
|
|
73
|
+
if (main) return { coreRoot: core, instanceRoot: main, mainCheckedOut: true };
|
|
74
|
+
const primary = findPrimary(dirname) || findPrimary(cwd);
|
|
75
|
+
return { coreRoot: core, instanceRoot: primary || core, mainCheckedOut: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { instanceRoot: INSTANCE_ROOT, mainCheckedOut: MAIN_CHECKED_OUT } = resolveRoots();
|
|
79
|
+
// The historical name every ship phase imports. It always meant "the checkout
|
|
80
|
+
// auto-merge runs in", i.e. the instance root — kept as an alias so the phases
|
|
81
|
+
// that only ever wanted that do not churn.
|
|
82
|
+
const REPO_ROOT = INSTANCE_ROOT;
|
|
50
83
|
|
|
51
84
|
// Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
|
|
52
85
|
// the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
|
|
@@ -68,6 +101,10 @@ module.exports = {
|
|
|
68
101
|
GRADER_DIFF_CHAR_CAP,
|
|
69
102
|
GRADER_TIMEOUT_MS,
|
|
70
103
|
REPO_ROOT,
|
|
104
|
+
CORE_ROOT,
|
|
105
|
+
INSTANCE_ROOT,
|
|
106
|
+
MAIN_CHECKED_OUT,
|
|
107
|
+
resolveRoots,
|
|
71
108
|
playSound,
|
|
72
109
|
BEHIND_MAIN_LIMIT,
|
|
73
110
|
};
|
|
@@ -50,6 +50,23 @@ function hubDeployUrl(config) {
|
|
|
50
50
|
return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// WHERE THIS PROJECT'S OWNER TAKES IT OFFLINE OR DISCONNECTS IT (task 1004570). Both
|
|
54
|
+
// controls live on the hub's Projects page for this project — the typed-name confirm and
|
|
55
|
+
// the intent queue are there (ADR 0290) — so the hall only links to them and gains no
|
|
56
|
+
// second delete path. Same two inputs as hubDeployUrl (the hub origin and this project's
|
|
57
|
+
// client id, which is its slug); null on a self-hosted instance or the hub itself, so the
|
|
58
|
+
// hall shows no section rather than a dead link. The page resolves ?project=<slug>
|
|
59
|
+
// against the visitor's OWN projects, so the link opens nothing for anyone else.
|
|
60
|
+
function hubManageLinks(config) {
|
|
61
|
+
if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
|
|
62
|
+
if (!/^https?:\/\/[^\s"'<>]+$/i.test(config.origin)) return null;
|
|
63
|
+
const origin = config.origin.replace(/\/+$/, '');
|
|
64
|
+
const page = `${origin}/projects?view=manage&project=${encodeURIComponent(String(config.clientId))}`;
|
|
65
|
+
let host = origin;
|
|
66
|
+
try { host = new URL(origin).host; } catch { /* keep the origin as written */ }
|
|
67
|
+
return { hub_host: host, manage_url: page, offline_url: `${page}#mDelete`, disconnect_url: `${page}#mDisconnect` };
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
// WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
|
|
54
71
|
// Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
|
|
55
72
|
// door is the provisioning module's — so only a hall that runs provisioning has one.
|
|
@@ -216,9 +233,25 @@ function buildCoreUpdateRouter({
|
|
|
216
233
|
}, { errorCode: 'software_update_failed', message: false }),
|
|
217
234
|
);
|
|
218
235
|
|
|
236
|
+
// GET /project/manage-link — the hub's page for THIS project, where its owner takes it
|
|
237
|
+
// offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
|
|
238
|
+
// so the read is Archon-only: a builder below that is refused and the hall draws no
|
|
239
|
+
// section. { link: null } on a self-hosted instance (no hub to link to).
|
|
240
|
+
// rank: archon — the project's owner; it names where the project can be ended.
|
|
241
|
+
router.get(
|
|
242
|
+
'/project/manage-link',
|
|
243
|
+
auth.requireBuilder,
|
|
244
|
+
auth.requireRank('archon'),
|
|
245
|
+
asyncHandler('GET /project/manage-link', async (_req, res) => {
|
|
246
|
+
const { idp } = doorInputs();
|
|
247
|
+
res.json({ ok: true, link: hubManageLinks(idp) });
|
|
248
|
+
}, { errorCode: 'manage_link_failed', message: false }),
|
|
249
|
+
);
|
|
250
|
+
|
|
219
251
|
return router;
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
module.exports = buildCoreUpdateRouter;
|
|
255
|
+
module.exports.hubManageLinks = hubManageLinks;
|
|
223
256
|
module.exports.hubDeployUrl = hubDeployUrl;
|
|
224
257
|
module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
|