rvlib-shipkit 1.7.0 → 1.9.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
  10. package/src/checks/SK033-global-contribution.check.ts +47 -0
  11. package/src/cli/cmd_Ci.ts +5 -4
  12. package/src/cli/cmd_GlobalContext.ts +47 -0
  13. package/src/cli/cmd_GlobalProcesses.ts +38 -1
  14. package/src/cli.ts +4 -1
  15. package/src/collections/providers.ts +1 -1
  16. package/src/core/check-runner.ts +13 -4
  17. package/src/core/discover-checks.ts +37 -8
  18. package/src/core/fix-scope.ts +5 -0
  19. package/src/core/floor-resolve.ts +2 -1
  20. package/src/core/git-root.ts +22 -12
  21. package/src/core/global-contribution.ts +42 -0
  22. package/src/core/one-lib.ts +4 -2
  23. package/src/core/run-ci.ts +12 -11
  24. package/src/core/slots.ts +36 -6
  25. package/src/home/global-context.ts +145 -0
  26. package/src/home/gui-app/ShipkitGui.swift +96 -0
  27. package/src/home/home-routes.ts +10 -0
  28. package/src/home/resources.ts +3 -0
  29. package/src/plan/planner.ts +21 -4
  30. package/src/plan/repo-source.ts +7 -3
  31. package/src/release/run-release.ts +17 -8
  32. package/src/release/unit-gate.ts +12 -5
  33. package/src/repo-config.ts +7 -0
  34. package/src/serve/hub-runtime.ts +34 -23
  35. package/src/serve/project-gutter.ts +5 -2
  36. package/src/serve/run-lanes.ts +138 -0
  37. package/src/smoke/run-smoke.ts +13 -6
  38. package/src/suite/judge-suites.ts +79 -11
  39. package/src/suite/reads-env.ts +8 -0
  40. package/src/suite/select.ts +510 -0
  41. package/src/suite/suite-cli.ts +213 -23
  42. package/src/suite/suite-reads-preload.ts +335 -0
  43. package/src/ts/typecheck-cache.ts +11 -0
  44. package/src/ts/typecheck-cli.ts +2 -1
@@ -130,6 +130,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
130
130
 
131
131
  func applicationDidFinishLaunching(_ note: Notification) {
132
132
  if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
133
+ if CommandLine.arguments.contains("--self-test-panels") { return selfTestPanels() }
133
134
  buildMenu()
134
135
  // esc closes the find bar of the key window, and reaches the page otherwise
135
136
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
@@ -267,6 +268,61 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
267
268
  web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
268
269
  }
269
270
 
271
+ /** hears every js panel once it shows: the self test answers it */
272
+ var onPanel: ((NSAlert) -> Void)?
273
+
274
+ // `--self-test-panels`: a page in an offscreen window calls prompt, confirm
275
+ // and alert, answered OK after typing over the prompt's text, then again,
276
+ // answered with esc. What the page got printed, exit 0 only when every
277
+ // answer reached it. Return is checked as OK being the sheet's default
278
+ // button: a synthetic return reaches it only in a key window, and the test
279
+ // must not take the focus
280
+ func selfTestPanels() {
281
+ let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
282
+ web.navigationDelegate = self
283
+ web.uiDelegate = self
284
+ let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
285
+ win.contentView = Pane(web: web)
286
+ win.orderFrontRegardless()
287
+ windows.append(win)
288
+ var accept = true
289
+ var panels = 0
290
+ var returnIsOK = true
291
+ var escHeard = true
292
+ onPanel = { alert in
293
+ panels += 1
294
+ DispatchQueue.main.async {
295
+ if alert.window.defaultButtonCell !== alert.buttons[0].cell { returnIsOK = false }
296
+ if accept {
297
+ // typing replaces the default text only when it is selected in an active editor
298
+ if let field = alert.accessoryView as? NSTextField { (field.currentEditor() as? NSTextView)?.insertText("typed", replacementRange: NSRange(location: NSNotFound, length: 0)) }
299
+ return alert.buttons[0].performClick(nil)
300
+ }
301
+ if alert.buttons.count == 1 { return alert.buttons[0].performClick(nil) }
302
+ let esc = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: alert.window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)
303
+ if let esc, alert.window.performKeyEquivalent(with: esc) { return }
304
+ escHeard = false
305
+ alert.buttons[1].performClick(nil)
306
+ }
307
+ }
308
+ let js = "JSON.stringify([prompt('name', 'x'), confirm('ok?'), (alert('hi'), 'alerted')])"
309
+ onLoad = { _ in
310
+ web.evaluateJavaScript(js) { a, _ in
311
+ accept = false
312
+ web.evaluateJavaScript(js) { b, _ in
313
+ let ok = (a as? String) ?? "none", cancel = (b as? String) ?? "none"
314
+ print("panels=\(panels)")
315
+ print("return-is-ok=\(returnIsOK)")
316
+ print("esc-heard=\(escHeard)")
317
+ print("ok=\(ok)")
318
+ print("cancel=\(cancel)")
319
+ exit(panels == 6 && returnIsOK && escHeard && ok == "[\"typed\",true,\"alerted\"]" && cancel == "[null,false,\"alerted\"]" ? 0 : 1)
320
+ }
321
+ }
322
+ }
323
+ web.loadHTMLString("<p>panels</p>", baseURL: nil)
324
+ }
325
+
270
326
  func windowWillClose(_ note: Notification) {
271
327
  windows.removeAll { $0 === note.object as? NSWindow }
272
328
  }
@@ -293,6 +349,46 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
293
349
  return nil
294
350
  }
295
351
 
352
+ // alert, confirm and prompt: without these WebKit answers each one at once
353
+ // (no panel, confirm false, prompt null), so a page's dialogs do nothing.
354
+ // WebKit throws when a handler is dropped or called twice: `panel` calls
355
+ // `done` exactly once on every path
356
+
357
+ /** one js panel: a sheet on the page's window, a modal where there is
358
+ * none. Return is OK, esc is Cancel; `done` hears whether OK was pressed */
359
+ func panel(_ web: WKWebView, _ message: String, cancel: Bool, field: NSTextField? = nil, done: @escaping (Bool) -> Void) {
360
+ let alert = NSAlert()
361
+ alert.messageText = message
362
+ alert.addButton(withTitle: "OK").keyEquivalent = "\r"
363
+ if cancel { alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" }
364
+ if let field {
365
+ alert.accessoryView = field
366
+ alert.window.initialFirstResponder = field
367
+ }
368
+ guard let win = web.window else {
369
+ onPanel?(alert)
370
+ return done(alert.runModal() == .alertFirstButtonReturn)
371
+ }
372
+ alert.beginSheetModal(for: win) { r in done(r == .alertFirstButtonReturn) }
373
+ // first responder selects the whole default text, so typing replaces it
374
+ if let field { alert.window.makeFirstResponder(field) }
375
+ onPanel?(alert)
376
+ }
377
+
378
+ func webView(_ web: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
379
+ panel(web, message, cancel: false) { _ in completionHandler() }
380
+ }
381
+
382
+ func webView(_ web: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
383
+ panel(web, message, cancel: true) { ok in completionHandler(ok) }
384
+ }
385
+
386
+ func webView(_ web: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
387
+ let field = NSTextField(string: defaultText ?? "")
388
+ field.frame = NSRect(x: 0, y: 0, width: 300, height: 24)
389
+ panel(web, prompt, cancel: true, field: field) { ok in completionHandler(ok ? field.stringValue : nil) }
390
+ }
391
+
296
392
  func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
297
393
  let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
298
394
  i.keyEquivalentModifierMask = mods
@@ -18,6 +18,7 @@ import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
19
  import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
20
  import { devRepos, InsightsCache, type Scope } from './insights.ts'
21
+ import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
21
22
  import { HouseRuns } from '../serve/house-runs.ts'
22
23
 
23
24
  /** how far back a repo's git page lists its commits */
@@ -126,6 +127,7 @@ export function homeRoutes(d: HomeDeps) {
126
127
  }
127
128
 
128
129
  let active = new Set<string>()
130
+ let contextAt = -Infinity
129
131
  if (d.plan != null) d.plan.active = (name) => active.has(name)
130
132
 
131
133
  async function tick(): Promise<void> {
@@ -133,6 +135,14 @@ export function homeRoutes(d: HomeDeps) {
133
135
  active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
134
136
  // the plan's clock: one look per tick, and it starts at most one batch
135
137
  d.plan?.tick()
138
+ // the house's generated context: rewritten only when a source moved,
139
+ // and never held on
140
+ if (Date.now() - contextAt >= GLOBAL_CONTEXT_EVERY_MS) {
141
+ contextAt = Date.now()
142
+ void houseContext(d.root)
143
+ .then((c) => { if (c != null && writeIfChanged(c.path, c.text)) console.log(`ok: wrote ${c.path}`) })
144
+ .catch((e) => console.error(`WARN the global context could not be written: ${(e as Error).message}`))
145
+ }
136
146
  // a widget render never holds the tick back
137
147
  void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
138
148
  await d.hubs.tick(active)
@@ -285,6 +285,9 @@ export class Resource {
285
285
  * while it answers, else it starts and opens once it does */
286
286
  async open(how: OpenHow): Promise<void> {
287
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
288
+ // the status is the last tick's: a process that died since would get
289
+ // a window on a dead port and never start
290
+ await this.tick()
288
291
  if (this.status === 'up') {
289
292
  this.show(how, this.decl.url)
290
293
  return
@@ -11,7 +11,8 @@
11
11
  //
12
12
  // what keeps it off the cpu: a save burst is asked about once the repo is
13
13
  // quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
14
- // save moved waits COST_FACTOR times its last measured run, and nothing it
14
+ // save moved waits COST_FACTOR times its last measured run and GATED_HOLD_MS
15
+ // after the latest gate someone else started in its repo, and nothing it
15
16
  // starts on its own starts while the load per core is above LOAD_MAX.
16
17
  import { availableParallelism, loadavg } from 'os'
17
18
  import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
@@ -39,6 +40,9 @@ export type PlanSource = {
39
40
  checks: () => Promise<PlanCheck[]>
40
41
  /** every check's last result on record */
41
42
  last: () => Record<string, LastResult>
43
+ /** when the latest run someone asked for started here (a terminal, an
44
+ * agent, a click), never one the plan started; null when none did */
45
+ gatedAt?: () => number | null
42
46
  /** the checks a save moved: their answer would not replay */
43
47
  changed: () => Promise<string[]>
44
48
  /** run these checks now, in the background; resolves to the exit code */
@@ -88,6 +92,11 @@ export const MAX_WAIT_MS = 10 * 60_000
88
92
  * measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
89
93
  export const COST_FACTOR = 20
90
94
 
95
+ /** a save moved check waits this long after the latest run someone else
96
+ * started in its repo (a terminal, an agent, a click): whoever gates that
97
+ * tree is still at work, and its own gate measures what it changed */
98
+ export const GATED_HOLD_MS = 10 * 60_000
99
+
91
100
  /** above this 1 minute load average per core, the plan starts nothing on its
92
101
  * own: every core already has a process waiting for it */
93
102
  export const LOAD_MAX = 1
@@ -112,6 +121,7 @@ type Repo = {
112
121
  src: PlanSource
113
122
  checks: PlanCheck[]
114
123
  last: Record<string, LastResult>
124
+ gatedAt: number | null
115
125
  lastRead: number
116
126
  marks: Map<string, Mark>
117
127
  timer: unknown
@@ -169,7 +179,7 @@ export class Planner {
169
179
  async add(src: PlanSource): Promise<void> {
170
180
  const old = this.repos.get(src.repo)
171
181
  if (old?.timer != null) this.timers.clear(old.timer)
172
- const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
182
+ const r: Repo = { src, checks: [], last: {}, gatedAt: null, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
173
183
  this.repos.set(src.repo, r)
174
184
  await this.reload(src.repo)
175
185
  try {
@@ -223,6 +233,7 @@ export class Planner {
223
233
  r.lastRead = now
224
234
  try {
225
235
  r.last = r.src.last()
236
+ r.gatedAt = r.src.gatedAt?.() ?? null
226
237
  } catch (e) {
227
238
  this.fail(r.src.repo, e)
228
239
  }
@@ -315,7 +326,9 @@ export class Planner {
315
326
  // a check pays for its own runs: one save cannot run it again
316
327
  // before COST_FACTOR times its last measured run has passed
317
328
  const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
318
- const at = Math.max(m.changedAt, spaced)
329
+ // and it waits for whoever gates this tree by hand to stop
330
+ const gated = r.gatedAt != null ? r.gatedAt + GATED_HOLD_MS : -Infinity
331
+ const at = Math.max(m.changedAt, spaced, gated)
319
332
  return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
320
333
  }
321
334
  const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
@@ -354,8 +367,12 @@ export class Planner {
354
367
  held(): string[] {
355
368
  const now = this.now()
356
369
  const out: string[] = []
357
- for (const r of this.repos.values())
370
+ for (const r of this.repos.values()) {
358
371
  if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
372
+ const until = r.gatedAt == null ? null : r.gatedAt + GATED_HOLD_MS
373
+ if (until != null && until > now && [...r.marks.values()].some((m) => m.changedAt != null))
374
+ out.push(`deferred: ${r.src.repo}, a gate someone started there ${Math.floor((now - r.gatedAt!) / 60_000)}m ago, so what a save moved waits ${Math.ceil((until - now) / 60_000)}m`)
375
+ }
359
376
  if (this.holdReason != null) out.push(this.holdReason)
360
377
  return out
361
378
  }
@@ -82,12 +82,15 @@ export function readLastCosts(path: string): Record<string, { at: number; ms: nu
82
82
  return out
83
83
  }
84
84
 
85
- /** when the latest run on record started, whoever ran it, or null */
86
- export function lastStarted(path: string): number | null {
85
+ /** when the latest run on record started, or null. `asked`: only a run
86
+ * someone asked for (a terminal, an agent, a click), never one the plan
87
+ * started on its own */
88
+ export function lastStarted(path: string, o: { asked?: boolean } = {}): number | null {
87
89
  const opened = openDb(path, { create: false })
88
90
  if (opened == null) return null
89
91
  try {
90
- const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
92
+ const sql = o.asked === true ? "select max(started) as at from invocations where by != 'schedule'" : 'select max(started) as at from invocations'
93
+ const r = opened.db.query(sql).get() as { at: number | null } | null
91
94
  return r?.at == null ? null : Number(r.at)
92
95
  } catch {
93
96
  return null
@@ -144,6 +147,7 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
144
147
  for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
145
148
  return all
146
149
  },
150
+ gatedAt: () => (d.record === '' ? null : lastStarted(d.record, { asked: true })),
147
151
  changed: async () => {
148
152
  const decls = rows.map((r) => r.decl)
149
153
  if (decls.length === 0) return []
@@ -12,7 +12,7 @@ import { runCi } from '../core/run-ci.ts'
12
12
  import { extractChangelogSection } from './changelog.ts'
13
13
  import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
14
14
  import { releaseScope } from './release-scope.ts'
15
- import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
15
+ import { publishCommand, unitBlockers, type Publisher } from './unit-gate.ts'
16
16
  import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
17
17
  import type { RepoConfig } from '../repo-config.ts'
18
18
  import { nestedIn, nestedRefusal } from '../core/nesting.ts'
@@ -37,6 +37,10 @@ export type ReleaseConfig = {
37
37
  * Absent = rely on ambient npm auth. Never put a token in config.
38
38
  * Repo-wide: one account publishes every unit. */
39
39
  npmTokenCommand?: string[]
40
+ /** the tool that packs and publishes every npm unit (default 'npm'). A
41
+ * repo that tests its tarball with `bun pm pack` publishes with 'bun', so
42
+ * the packer it tests is the packer that ships. */
43
+ publisher?: Publisher
40
44
  /** cut a GitHub release for the ROOT unit with `gh` (default true) */
41
45
  githubRelease?: boolean
42
46
  /** make a NON-blocking check failure block the release too. Blocking
@@ -301,18 +305,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
301
305
  if (wantNpm && !alreadyPublished) {
302
306
  const env: Record<string, string> = { ...process.env as Record<string, string> }
303
307
  const argv = rc.npmTokenCommand
308
+ const publisher = rc.publisher ?? 'npm'
304
309
  let npmrc: string | null = null
310
+ let token: string | null = null
305
311
  if (argv != null && argv[0] != null) {
306
312
  const t = Bun.spawnSync(argv)
307
- const token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
313
+ token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
308
314
  if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
309
- // token via a throwaway userconfig, never argv, never a
310
- // committed .npmrc, never a line in a log
311
- npmrc = join(work, 'npmrc')
312
- writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
315
+ // token via a throwaway userconfig (npm) or the env (bun),
316
+ // never argv, never a committed .npmrc, never a line in a log
317
+ if (publisher === 'npm') {
318
+ npmrc = join(work, 'npmrc')
319
+ writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
320
+ }
313
321
  }
314
- const p = Bun.spawnSync(npmPublishArgv({ npmrc }), { cwd: unit.dir, env, stdout: 'inherit', stderr: 'inherit' })
315
- if (p.exitCode !== 0) { print(`FAIL: npm publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
322
+ const cmd = publishCommand({ publisher, npmrc, token })
323
+ const p = Bun.spawnSync(cmd.argv, { cwd: unit.dir, env: { ...env, ...cmd.env }, stdout: 'inherit', stderr: 'inherit' })
324
+ if (p.exitCode !== 0) { print(`FAIL: ${publisher} publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
316
325
  print(`ok: published ${name}@${version}`)
317
326
  }
318
327
 
@@ -9,11 +9,18 @@ import { dirtyPaths } from './git-facts.ts'
9
9
  import type { ReleaseUnit } from './units.ts'
10
10
  import type { RepoConfig } from '../repo-config.ts'
11
11
 
12
- /** the publish argv. `--access public` is not a default: npm restricts a
13
- * scoped package unless it is asked, and a restricted publish looks
14
- * successful right up until a consumer cannot install it. */
15
- export function npmPublishArgv(o: { npmrc?: string | null } = {}): string[] {
16
- return ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])]
12
+ /** the tool that packs and publishes an npm unit (`release.publisher`) */
13
+ export const PUBLISHERS = ['npm', 'bun'] as const
14
+ export type Publisher = (typeof PUBLISHERS)[number]
15
+
16
+ /** the publish argv and the env its token rides in. `--access public` is not
17
+ * a default: npm restricts a scoped package unless it is asked, and a
18
+ * restricted publish looks successful right up until a consumer cannot
19
+ * install it. npm reads the token from a throwaway userconfig; `bun publish`
20
+ * has no `--userconfig` and reads NPM_CONFIG_TOKEN instead. */
21
+ export function publishCommand(o: { publisher?: Publisher; npmrc?: string | null; token?: string | null } = {}): { argv: string[]; env: Record<string, string> } {
22
+ if (o.publisher === 'bun') return { argv: ['bun', 'publish', '--access', 'public'], env: o.token != null ? { NPM_CONFIG_TOKEN: o.token } : {} }
23
+ return { argv: ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])], env: {} }
17
24
  }
18
25
 
19
26
  /** everything that must hold for this unit, checked before anything is
@@ -25,6 +25,7 @@ import type { Rule } from './check.ts'
25
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
26
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
27
27
  import type { ReleaseConfig } from './release/run-release.ts'
28
+ import { PUBLISHERS } from './release/unit-gate.ts'
28
29
  import { TEST_ONLY_UNIT_KEYS, UNIT_DECL_KEYS, type UnitDecl } from './release/unit-resolve.ts'
29
30
  import { TASK_DEF_KEYS, type TaskDef } from './check.ts'
30
31
  import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
@@ -41,6 +42,10 @@ export type ServeConfig = {
41
42
  * its own port — house range 5800+, default = the hub port plus 1000
42
43
  * (build/comfyui.md owns that surface) */
43
44
  comfyPort?: number
45
+ /** false hides the asset kit from the hub page (no asset rows, an empty
46
+ * studio) and from `shipkit needs`, for a repo whose own tool mounts
47
+ * `studioRoutes` and owns the picking. Default true */
48
+ assets?: boolean
44
49
  /** custom wiki-graph provider for repos with their own link semantics.
45
50
  * Default: a generic md scan of the repo root. */
46
51
  wikiGraph?: {
@@ -320,6 +325,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
320
325
  for (const k of Object.keys(c))
321
326
  if (!KNOWN_KEYS.has(k))
322
327
  throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
328
+ if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
329
+ throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
323
330
  // a typo'd unit key would silently drop a target and release the wrong shape
324
331
  for (const u of c.units ?? []) {
325
332
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
@@ -41,6 +41,7 @@ import { autoGatePool, takeSlot } from '../core/slots.ts'
41
41
  import { liveJobs, startJob } from '../core/jobs.ts'
42
42
  import { followJob } from '../core/job-follow.ts'
43
43
  import { RUN_JOB } from '../home/home-words.ts'
44
+ import { RunLanes, runKey, treeStamp, writesTree } from './run-lanes.ts'
44
45
  import { OtherRepos } from './other-repos.ts'
45
46
  import { HouseRuns } from './house-runs.ts'
46
47
  import { recordStamp } from './project-gutter.ts'
@@ -157,9 +158,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
157
158
  graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
158
159
  // ONE page for every repo: the studio ALWAYS
159
160
  // mounts — kit-less repos get an empty media section, never a second
160
- // page. A broken kit degrades to the same empty kit, loudly.
161
+ // page. A broken kit degrades to the same empty kit, loudly, and a repo
162
+ // whose own tool owns the picking turns the kit off here
161
163
  let kit: ResolvedKit | null = null
162
- if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
164
+ if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
163
165
  try {
164
166
  kit = await loadKit(root)
165
167
  } catch (e) {
@@ -185,9 +187,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
185
187
  const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
186
188
  // every run this process has going, whoever started it
187
189
  let active = 0
188
- // ONE run at a time, whoever asked: two gates over one tree would measure
189
- // the same checks twice and race each other's repair pass
190
- let chain: Promise<unknown> = Promise.resolve()
190
+ // every run, whoever asked: an identical one attaches, a repair pass goes
191
+ // alone, the rest overlap under the machine slot pool (run-lanes.ts)
192
+ const lanes = new RunLanes(() => treeStamp(root))
193
+ // the gate jobs this runtime started: one beside another is the lanes' call
194
+ const mine = new Set<string>()
191
195
  // the latest verdict of every check, printed as one problem frame after
192
196
  // each run for the vscode problems pane (problems/problem-line.ts)
193
197
  const board = new ProblemBoard()
@@ -215,13 +219,22 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
215
219
  }
216
220
  }
217
221
  // a job still running here, started before this image or by a terminal's --detach
218
- while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
222
+ const foreign = (): boolean => liveJobs(root).some((j) => !mine.has(j.id))
223
+ if (foreign()) tap.line('note: a gate job this hub did not start is running here, so this one starts when it ends')
224
+ while (foreign()) await Bun.sleep(jobs.pollMs ?? 250)
219
225
  const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
226
+ mine.add(j.id)
220
227
  tap.job?.(j.id)
221
- return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
228
+ try {
229
+ return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
230
+ } finally {
231
+ mine.delete(j.id)
232
+ }
222
233
  }
223
234
  const runChecks: HubDeps['runChecks'] = (tap, o) => {
224
- const run = chain.then(async () => {
235
+ // only a whole gate the hub was asked for runs the repair pass (hubGateOpts)
236
+ const writes = o?.auto !== true && o?.gate === true && (o.only == null || o.only.length === 0)
237
+ return lanes.submit({ key: null, writes, print: tap.line }, async () => {
225
238
  active++
226
239
  // a run the hub starts on its own takes the machine's background
227
240
  // turn, once it is this hub's turn, never while it queues
@@ -243,27 +256,25 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
243
256
  for (const l of board.frame()) io.print(l)
244
257
  }
245
258
  })
246
- chain = run.catch(() => {})
247
- return run
248
259
  }
249
260
  // a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
250
- // in the same one-at-a-time chain as the hub's own runs, feeding the same
251
- // problems frame, recorded as the cli's
261
+ // in the same lanes as the hub's own runs, feeding the same problems
262
+ // frame, recorded as the cli's
252
263
  const runCli: HubDeps['runCli'] = (kind, opts, print) => {
253
- // never on a --json run: its output is the json and nothing else
254
- if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
255
- const run = chain.then(async () => {
264
+ // a --json run prints the json and nothing else, so it never shares
265
+ const key = opts.json === true ? null : runKey(kind, opts)
266
+ // `check --fix` runs in process (home/via-hub.ts), so a check here never repairs
267
+ const writes = kind === 'ci' && writesTree(opts)
268
+ return lanes.submit({ key, writes, print, quiet: opts.json === true }, async (tee) => {
256
269
  active++
257
270
  try {
258
271
  await refresh()
259
- return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
272
+ return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: tee, check: onBoard, job: (id) => tee(`${RUN_JOB}${id}`) }, false)
260
273
  } finally {
261
274
  active--
262
275
  for (const l of board.frame()) io.print(l)
263
276
  }
264
277
  })
265
- chain = run.catch(() => {})
266
- return run
267
278
  }
268
279
  const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
269
280
  const only = o?.only != null && o.only.length > 0 ? o.only : undefined
@@ -287,11 +298,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
287
298
  if (adopted.length > 0) {
288
299
  io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
289
300
  active++
290
- const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
291
- active--
292
- for (const l of board.frame()) io.print(l)
293
- })
294
- chain = run.catch(() => {})
301
+ void lanes.submit({ key: null, writes: true, print: () => {} }, () =>
302
+ Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).then(() => 0).finally(() => {
303
+ active--
304
+ for (const l of board.frame()) io.print(l)
305
+ })).catch(() => {})
295
306
  }
296
307
  }
297
308
  // resolved once: it shells out to git, and the page asks on every poll
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
10
  import { basename, dirname, join, resolve, sep } from 'path'
11
11
  import { fileURLToPath } from 'url'
12
- import { gitRoot } from '../core/git-root.ts'
12
+ import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
13
13
  import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
52
52
 
53
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
54
54
 
55
- /** every direct child of `parent` that includes `index`, sorted by name */
55
+ /** every direct child of `parent` that includes `index`, sorted by name. A
56
+ * linked worktree of one is that repo checked out again, never a repo of its
57
+ * own: its processes would collide with the main checkout's ids and ports */
56
58
  export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
57
59
  let names: string[]
58
60
  try {
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
65
67
  if (name.startsWith('.')) continue
66
68
  const path = join(parent, name)
67
69
  if (!includeTargets(path, home).includes(index)) continue
70
+ if (isLinkedWorktree(path)) continue
68
71
  out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
69
72
  }
70
73
  return out