rvlib-shipkit 1.2.0 → 1.3.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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +19 -2
- package/src/ledger/world-cache.ts +51 -4
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +11 -3
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +11 -7
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +20 -3
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// the activity page's shapes and its few pure rules, apart from the git
|
|
2
|
+
// reader so the page bundle takes them without node's modules. The words
|
|
3
|
+
// here are what the page and `shipkit cross-repo-activity` both print.
|
|
4
|
+
import type { CheckState } from '../serve/project-gutter.ts'
|
|
5
|
+
|
|
6
|
+
/** the windows the page offers, in minutes */
|
|
7
|
+
export const WINDOW_MINUTES = [10, 30, 60, 180, 720, 1440, 4320, 10080] as const
|
|
8
|
+
export const DEFAULT_MINUTES = 30
|
|
9
|
+
/** the most commits the daemon lists per repo, and the most uncommitted paths it names */
|
|
10
|
+
export const MAX_COMMITS = 100
|
|
11
|
+
export const MAX_FILES = 80
|
|
12
|
+
|
|
13
|
+
export type FileKind = 'staged' | 'modified' | 'untracked'
|
|
14
|
+
|
|
15
|
+
export type ActivityCommit = {
|
|
16
|
+
sha: string
|
|
17
|
+
/** committer time, epoch ms */
|
|
18
|
+
at: number
|
|
19
|
+
title: string
|
|
20
|
+
author: string
|
|
21
|
+
/** the branch it sits on when it is not on the checked out one, else null */
|
|
22
|
+
branch: string | null
|
|
23
|
+
/** a remote holds it; false on a branch with no upstream */
|
|
24
|
+
pushed: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ActivityRow = {
|
|
28
|
+
name: string
|
|
29
|
+
/** the checked out branch; null when detached (see `detached`) */
|
|
30
|
+
branch: string | null
|
|
31
|
+
/** the short sha HEAD sits on when detached */
|
|
32
|
+
detached: string | null
|
|
33
|
+
/** the upstream as configured, null when the branch has none */
|
|
34
|
+
upstream: string | null
|
|
35
|
+
/** the upstream is configured but no longer exists */
|
|
36
|
+
gone: boolean
|
|
37
|
+
/** commits ahead of and behind the upstream, null without one */
|
|
38
|
+
ahead: number | null
|
|
39
|
+
behind: number | null
|
|
40
|
+
counts: Record<FileKind, number>
|
|
41
|
+
/** the uncommitted paths, at most MAX_FILES; a path both staged and
|
|
42
|
+
* modified is listed once, as modified */
|
|
43
|
+
files: { kind: FileKind; path: string }[]
|
|
44
|
+
/** in the window, newest first */
|
|
45
|
+
commits: ActivityCommit[]
|
|
46
|
+
/** HEAD's latest commit when nothing was committed in the window, shown
|
|
47
|
+
* grey; null when the window holds a commit, or the repo has none */
|
|
48
|
+
last: ActivityCommit | null
|
|
49
|
+
/** its checks as its run record last saw them, only the ids it declares
|
|
50
|
+
* today when that is known; null when shipkit never ran there */
|
|
51
|
+
status: CheckState
|
|
52
|
+
/** the checks behind a red or orange status, failing first: what the
|
|
53
|
+
* status column lists on hover and on click */
|
|
54
|
+
failing?: StatusCheck[]
|
|
55
|
+
/** why git could not answer, else null */
|
|
56
|
+
error: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** one failing or warning check as the run record last saw it */
|
|
60
|
+
export type StatusCheck = {
|
|
61
|
+
id: string
|
|
62
|
+
/** its title as the repo declares it, else its id */
|
|
63
|
+
title: string
|
|
64
|
+
status: 'fail' | 'warn'
|
|
65
|
+
/** when that outcome was recorded, epoch ms */
|
|
66
|
+
at: number | null
|
|
67
|
+
/** the first line it printed about the problem, '' when it printed none */
|
|
68
|
+
line: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
|
|
72
|
+
|
|
73
|
+
export type ActivityRepo = { name: string; path: string }
|
|
74
|
+
|
|
75
|
+
/** the window a request names, when it is one the page offers */
|
|
76
|
+
export function windowMinutes(v: string | null): number {
|
|
77
|
+
const n = Number(v)
|
|
78
|
+
return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** every house repo ships from main: shown green, any other orange, a
|
|
82
|
+
* leftover master included so the repo still to rename stands out */
|
|
83
|
+
export const isMainBranch = (b: string | null): boolean => b === 'main'
|
|
84
|
+
|
|
85
|
+
/** nothing committed in the window and nothing uncommitted: it sits below the busy repos */
|
|
86
|
+
export const idleRow = (r: ActivityRow): boolean =>
|
|
87
|
+
r.error == null && r.commits.length === 0 && r.counts.staged + r.counts.modified + r.counts.untracked === 0
|
|
88
|
+
|
|
89
|
+
/** the most commits a repo shows: a number from CAP_MIN to CAP_MAX */
|
|
90
|
+
export const CAP_MIN = 1
|
|
91
|
+
export const CAP_MAX = 15
|
|
92
|
+
export const DEFAULT_CAP = 3
|
|
93
|
+
|
|
94
|
+
/** a window's label on the toggle: `10m`, `3h`, `3d` */
|
|
95
|
+
export const windowWords = (m: number): string => (m < 60 ? `${m}m` : m < 4320 ? `${m / 60}h` : `${m / 1440}d`)
|
|
96
|
+
|
|
97
|
+
/** a typed cap, as a whole number in CAP_MIN..CAP_MAX; the default when it is no number */
|
|
98
|
+
export function clampCap(v: unknown): number {
|
|
99
|
+
const n = Math.round(Number(v))
|
|
100
|
+
return Number.isFinite(n) && String(v).trim() !== '' ? Math.min(CAP_MAX, Math.max(CAP_MIN, n)) : DEFAULT_CAP
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** an age in one short unit: `now`, `4m`, `2h`, `3d` */
|
|
104
|
+
export function ageShort(at: number, now: number): string {
|
|
105
|
+
const s = Math.max(0, Math.round((now - at) / 1000))
|
|
106
|
+
if (s < 60) return 'now'
|
|
107
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
108
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
109
|
+
return `${Math.floor(s / 86400)}d`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** the uncommitted work in one line: `3 modified, 1 untracked`; '' when clean */
|
|
113
|
+
export function dirtWords(r: ActivityRow): string {
|
|
114
|
+
const c = r.counts
|
|
115
|
+
return [c.staged > 0 ? `${c.staged} staged` : '', c.modified > 0 ? `${c.modified} modified` : '', c.untracked > 0 ? `${c.untracked} untracked` : '']
|
|
116
|
+
.filter((x) => x !== '')
|
|
117
|
+
.join(', ')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** where the branch stands against its upstream, for the branch's hover */
|
|
121
|
+
export function upstreamWords(r: ActivityRow): string {
|
|
122
|
+
if (r.upstream == null) return 'no upstream: nothing on this branch is pushed'
|
|
123
|
+
if (r.gone) return `${r.upstream} is gone`
|
|
124
|
+
const a = r.ahead ?? 0
|
|
125
|
+
const b = r.behind ?? 0
|
|
126
|
+
if (a === 0 && b === 0) return `even with ${r.upstream}`
|
|
127
|
+
return [a > 0 ? `${a} unpushed` : '', b > 0 ? `${b} behind` : ''].filter((x) => x !== '').join(', ') + ` against ${r.upstream}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** the uncommitted paths, one per line, for the hover */
|
|
131
|
+
export const filesWords = (r: ActivityRow): string => {
|
|
132
|
+
const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
|
|
133
|
+
const shown = r.files.map((f) => `${mark[f.kind]} ${f.path}`)
|
|
134
|
+
return (r.files.length >= MAX_FILES ? [...shown, '…'] : shown).join('\n')
|
|
135
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// the activity page's data: for every house repo, its branch, how far it is
|
|
2
|
+
// from its upstream, what it holds uncommitted and what it committed in a
|
|
3
|
+
// window. Plain local git, never a fetch, so "ahead" is against the upstream
|
|
4
|
+
// as this disk last saw it.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: this runs only when the page asks
|
|
7
|
+
// (no tick). A pass reads ACTIVITY_CONCURRENCY repos at once, and an answer is
|
|
8
|
+
// shared for ACTIVITY_TTL_MS per window, never served past it, so a page left
|
|
9
|
+
// open costs about one pass a minute however many tabs show it, and a closed
|
|
10
|
+
// one costs nothing.
|
|
11
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
12
|
+
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
13
|
+
import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
|
|
14
|
+
import type { Line } from '../core/wire.ts'
|
|
15
|
+
import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
|
|
16
|
+
|
|
17
|
+
export * from './activity-shape.ts'
|
|
18
|
+
|
|
19
|
+
/** how long one window's answer is shared between readers: half the page's
|
|
20
|
+
* 60s cadence, so tabs a few seconds apart share a pass and none is shown
|
|
21
|
+
* an answer older than this */
|
|
22
|
+
export const ACTIVITY_TTL_MS = 30_000
|
|
23
|
+
|
|
24
|
+
export type ParsedStatus = Pick<ActivityRow, 'branch' | 'detached' | 'upstream' | 'gone' | 'ahead' | 'behind' | 'counts' | 'files'>
|
|
25
|
+
|
|
26
|
+
/** `git status --porcelain=v2 --branch`, read */
|
|
27
|
+
export function parseStatus(text: string): ParsedStatus {
|
|
28
|
+
const out: ParsedStatus = { branch: null, detached: null, upstream: null, gone: false, ahead: null, behind: null, counts: { staged: 0, modified: 0, untracked: 0 }, files: [] }
|
|
29
|
+
let oid = ''
|
|
30
|
+
const add = (kind: FileKind, path: string): void => {
|
|
31
|
+
if (out.files.length < MAX_FILES) out.files.push({ kind, path })
|
|
32
|
+
}
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
if (line.startsWith('# branch.oid ')) oid = line.slice(13)
|
|
35
|
+
else if (line.startsWith('# branch.head ')) {
|
|
36
|
+
const h = line.slice(14)
|
|
37
|
+
if (h !== '(detached)') out.branch = h
|
|
38
|
+
} else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
|
|
39
|
+
else if (line.startsWith('# branch.ab ')) {
|
|
40
|
+
const m = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
|
|
41
|
+
if (m != null) {
|
|
42
|
+
out.ahead = Number(m[1])
|
|
43
|
+
out.behind = Number(m[2])
|
|
44
|
+
}
|
|
45
|
+
} else if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
46
|
+
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path<TAB>orig`
|
|
47
|
+
const f = line.split(' ')
|
|
48
|
+
const xy = f[1] ?? '..'
|
|
49
|
+
const path = f.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0]!
|
|
50
|
+
if (xy[0] !== '.') out.counts.staged++
|
|
51
|
+
if (xy[1] !== '.') out.counts.modified++
|
|
52
|
+
add(xy[1] !== '.' ? 'modified' : 'staged', path)
|
|
53
|
+
} else if (line.startsWith('u ')) {
|
|
54
|
+
out.counts.modified++
|
|
55
|
+
add('modified', line.split(' ').slice(10).join(' '))
|
|
56
|
+
} else if (line.startsWith('? ')) {
|
|
57
|
+
out.counts.untracked++
|
|
58
|
+
add('untracked', line.slice(2))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (out.branch == null && oid !== '' && oid !== '(initial)') out.detached = oid.slice(0, 7)
|
|
62
|
+
if (out.upstream != null && out.ahead == null) out.gone = true
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SEP = '\x1f'
|
|
67
|
+
const END = '\x1e'
|
|
68
|
+
const FORMAT = `--format=%h${SEP}%ct${SEP}%an${SEP}%S${SEP}%s${SEP}%H${END}`
|
|
69
|
+
|
|
70
|
+
/** `git log` in FORMAT, read; `unpushed` holds the full shas no remote has */
|
|
71
|
+
export function parseLog(text: string, onHead: boolean, unpushed: (full: string) => boolean = () => false): ActivityCommit[] {
|
|
72
|
+
const out: ActivityCommit[] = []
|
|
73
|
+
for (const rec of text.split(END)) {
|
|
74
|
+
const f = rec.replace(/^\n/, '').split(SEP)
|
|
75
|
+
if (f.length < 6) continue
|
|
76
|
+
const src = f[3]!.replace(/^refs\/heads\//, '')
|
|
77
|
+
out.push({ sha: f[0]!, at: Number(f[1]) * 1000, author: f[2]!, branch: onHead || src === '' ? null : src, title: f[4]!, pushed: !unpushed(f[5]!) })
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
83
|
+
// no optional locks: a look never takes the index lock an agent's commit needs
|
|
84
|
+
const p = Bun.spawn([gitBin(), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
|
|
85
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
86
|
+
return { code, out, err: err.trim() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** which check ids a repo declares today: the daemon's plan; null = not
|
|
90
|
+
* known, every recorded check counts */
|
|
91
|
+
export type Declared = (name: string) => ReadonlySet<string> | null
|
|
92
|
+
|
|
93
|
+
/** each declared check's title by id; null = not known, the id stands in */
|
|
94
|
+
export type Titles = (name: string) => ReadonlyMap<string, string> | null
|
|
95
|
+
|
|
96
|
+
/** the line a check printed about its problem: its first line of the same
|
|
97
|
+
* weight (a fail for a failure, a warning for a warning), else its first
|
|
98
|
+
* line that is not a pass, without the id its line leads with */
|
|
99
|
+
export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
|
|
100
|
+
let parsed: Line[]
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(lines) as Line[]
|
|
103
|
+
} catch {
|
|
104
|
+
return ''
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(parsed)) return ''
|
|
107
|
+
const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
|
|
108
|
+
const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
|
|
109
|
+
if (hit == null) return ''
|
|
110
|
+
const t = hit.text.trim()
|
|
111
|
+
// `[ID] text`, and a runner line's `ID — text`: the list names the id already
|
|
112
|
+
for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
|
|
113
|
+
return t
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** a repo's checks as its run record last saw them, counted over what it
|
|
117
|
+
* declares, and the failing and warning ones with what each said */
|
|
118
|
+
export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
|
|
119
|
+
const record = recordOf(repo.path)
|
|
120
|
+
if (record == null) return { status: null, failing: [] }
|
|
121
|
+
const rows = readLastStatusReadonly(record, { lines: true })
|
|
122
|
+
const status = checkStateOf(rows, declared?.(repo.name) ?? null)
|
|
123
|
+
if (status == null) return { status, failing: [] }
|
|
124
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
125
|
+
const named = titles?.(repo.name) ?? null
|
|
126
|
+
const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
|
|
127
|
+
const r = byId.get(id)
|
|
128
|
+
return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
|
|
129
|
+
}
|
|
130
|
+
return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** one repo, from `since` (epoch ms) to now */
|
|
134
|
+
export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
|
|
135
|
+
const empty = parseStatus('')
|
|
136
|
+
const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
|
|
137
|
+
try {
|
|
138
|
+
const iso = new Date(since).toISOString()
|
|
139
|
+
const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
|
|
140
|
+
// every ask that needs no other answer goes at once: git calls are
|
|
141
|
+
// ~5ms each but queue behind a busy daemon, so waiting for them one
|
|
142
|
+
// by one multiplies the wait. The upstream diff and the last commit
|
|
143
|
+
// are cheap enough to ask even when the answer goes unused
|
|
144
|
+
const [st, up, head, refs, one] = await Promise.all([
|
|
145
|
+
git(repo.path, ['status', '--porcelain=v2', '--branch']),
|
|
146
|
+
git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
|
|
147
|
+
// a repo with no commit yet has no HEAD to walk: nothing committed
|
|
148
|
+
git(repo.path, ['log', ...range, 'HEAD']),
|
|
149
|
+
// the other local branches (an agent's worktree) whose tip moved in
|
|
150
|
+
// the window, minus what HEAD has: a repo with hundreds of branches
|
|
151
|
+
// walks only the few that moved
|
|
152
|
+
git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
|
|
153
|
+
git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
|
|
154
|
+
])
|
|
155
|
+
if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
|
|
156
|
+
Object.assign(row, parseStatus(st.out))
|
|
157
|
+
// HEAD's unpushed commits: all of them without an upstream, else the
|
|
158
|
+
// `ahead` ones
|
|
159
|
+
const headAway = row.upstream == null || row.gone
|
|
160
|
+
const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
|
|
161
|
+
const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
|
|
162
|
+
const moved = (refs.code === 0 ? refs.out.split('\n') : [])
|
|
163
|
+
.map((l) => l.split('\t'))
|
|
164
|
+
.filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
|
|
165
|
+
// an upstream that is gone holds nothing
|
|
166
|
+
.map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
|
|
167
|
+
let other: ActivityCommit[] = []
|
|
168
|
+
if (moved.length > 0) {
|
|
169
|
+
const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
|
|
170
|
+
// a side branch's commit is pushed once its own upstream holds it;
|
|
171
|
+
// on a failed ask every one reads unpushed, never pushed
|
|
172
|
+
const tracked = moved.filter((f) => (f[2] ?? '') !== '')
|
|
173
|
+
let sideAway = new Set<string>()
|
|
174
|
+
if (tracked.length > 0) {
|
|
175
|
+
const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
|
|
176
|
+
if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
|
|
177
|
+
else sideAway = new Set(['*'])
|
|
178
|
+
}
|
|
179
|
+
const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
|
|
180
|
+
other = side.code === 0 ? parseLog(side.out, false, (h) => sideAway.has('*') || sideAway.has(h)).map((c) => (c.branch != null && untracked.has(c.branch) ? { ...c, pushed: false } : c)) : []
|
|
181
|
+
}
|
|
182
|
+
row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
|
|
183
|
+
// an empty window still says what came last on HEAD
|
|
184
|
+
if (row.commits.length === 0) {
|
|
185
|
+
row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
|
|
186
|
+
}
|
|
187
|
+
return row
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { ...row, error: (e as Error).message }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** how many repos a pass reads at once: five git calls each, so a pass
|
|
194
|
+
* never has more than 30 git processes alive */
|
|
195
|
+
export const ACTIVITY_CONCURRENCY = 6
|
|
196
|
+
|
|
197
|
+
/** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
|
|
198
|
+
* THE computation: the daemon's route and `shipkit cross-repo-activity`
|
|
199
|
+
* both call it, and only render it differently */
|
|
200
|
+
export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
|
|
201
|
+
const t0 = performance.now()
|
|
202
|
+
const rows: ActivityRow[] = new Array(repos.length)
|
|
203
|
+
let next = 0
|
|
204
|
+
const worker = async (): Promise<void> => {
|
|
205
|
+
while (next < repos.length) {
|
|
206
|
+
const i = next++
|
|
207
|
+
rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
|
|
211
|
+
return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** one answer per window, shared for ttlMs. Past it a read waits for a new
|
|
215
|
+
* pass: an old answer is never served as current. Callers during a pass
|
|
216
|
+
* share it. */
|
|
217
|
+
export class ActivityCache {
|
|
218
|
+
private kept = new Map<number, ActivityAnswer>()
|
|
219
|
+
private running = new Map<number, Promise<ActivityAnswer>>()
|
|
220
|
+
private ttlMs: number
|
|
221
|
+
private now: () => number
|
|
222
|
+
private declared: Declared | null
|
|
223
|
+
private titles: Titles | null
|
|
224
|
+
|
|
225
|
+
constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
|
|
226
|
+
this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
|
|
227
|
+
this.now = o.now ?? Date.now
|
|
228
|
+
this.declared = o.declared ?? null
|
|
229
|
+
this.titles = o.titles ?? null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
|
|
233
|
+
const going = this.running.get(minutes)
|
|
234
|
+
if (going != null) return going
|
|
235
|
+
const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
|
|
236
|
+
.then((a) => { this.kept.set(minutes, a); return a })
|
|
237
|
+
.finally(() => this.running.delete(minutes))
|
|
238
|
+
this.running.set(minutes, p)
|
|
239
|
+
return p
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** the kept answer while it is younger than the ttl, else a new pass.
|
|
243
|
+
* `fresh` always waits for a pass: the page's refresh button */
|
|
244
|
+
get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
|
|
245
|
+
const k = this.kept.get(minutes)
|
|
246
|
+
if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
|
|
247
|
+
return Promise.resolve(k)
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/home/global-units.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type GlobalProcessDecl = {
|
|
|
21
21
|
cwd: string
|
|
22
22
|
url: string | null
|
|
23
23
|
probe: string | null
|
|
24
|
-
|
|
24
|
+
/** as declared: an emoji, or an image path relative to root */
|
|
25
|
+
icon: string | null
|
|
25
26
|
actions: ActionDecl[]
|
|
26
27
|
/** the repo that declares it, and the file, repo-relative */
|
|
27
28
|
repo: string
|
|
@@ -51,7 +52,7 @@ export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): Globa
|
|
|
51
52
|
cwd: resolve(repo.path, u.cwd ?? '.'),
|
|
52
53
|
url: u.url ?? null,
|
|
53
54
|
probe: u.probe ?? u.url ?? null,
|
|
54
|
-
|
|
55
|
+
icon: u.icon ?? null,
|
|
55
56
|
actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
|
|
56
57
|
repo: repo.name,
|
|
57
58
|
root: repo.path,
|
package/src/home/home-routes.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { tailLines } from './supervise.ts'
|
|
|
16
16
|
import { Hubs, type WorkerView } from './hubs.ts'
|
|
17
17
|
import type { Planner, PlanRow } from '../plan/planner.ts'
|
|
18
18
|
import type { Widgets, WidgetView } from './widgets.ts'
|
|
19
|
+
import { ActivityCache, windowMinutes } from './activity.ts'
|
|
20
|
+
import { HouseRuns } from '../serve/house-runs.ts'
|
|
21
|
+
import type { HouseView } from '../ledger/house-runs.ts'
|
|
19
22
|
|
|
20
23
|
/** how often the daemon looks at every repo */
|
|
21
24
|
export const TICK_MS = 2_000
|
|
@@ -76,10 +79,13 @@ export const runModernize = async (repo: WiredRepo, root: string, log: string):
|
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
/** active = an agent works in it, VS Code has it open, or it is flagged by
|
|
80
|
-
|
|
82
|
+
/** active = an agent works in it, VS Code has it open, or it is flagged by
|
|
83
|
+
* hand. The house repo is active whenever any repo is: its checks serve
|
|
84
|
+
* every repo (a login, a shared key), so they follow the machine's work */
|
|
85
|
+
export function activeSet(rows: ProjectRow[], flagged: string[], house?: string): Set<string> {
|
|
81
86
|
const out = new Set(flagged)
|
|
82
87
|
for (const r of rows) if ((r.sessions != null && r.sessions > 0) || r.vscode === true) out.add(r.name)
|
|
88
|
+
if (house != null && out.size > 0) out.add(house)
|
|
83
89
|
return out
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -87,13 +93,24 @@ export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
|
|
|
87
93
|
export type WidgetsAnswer = { widgets: WidgetView[]; errors: string[] }
|
|
88
94
|
|
|
89
95
|
/** the planned page's data: every row of the machine's plan */
|
|
90
|
-
export type PlannedAnswer = {
|
|
96
|
+
export type PlannedAnswer = {
|
|
97
|
+
now: number
|
|
98
|
+
rows: PlanRow[]
|
|
99
|
+
errors: { repo: string; error: string }[]
|
|
100
|
+
running: boolean
|
|
101
|
+
/** what holds work back, one line each (Planner.held) */
|
|
102
|
+
held: string[]
|
|
103
|
+
}
|
|
91
104
|
|
|
92
105
|
export function homeRoutes(d: HomeDeps) {
|
|
93
106
|
let repos: WiredRepo[] = []
|
|
94
107
|
let rows: ProjectRow[] = []
|
|
95
108
|
const modernizing = new Map<string, ModernizeView & { log: string }>()
|
|
109
|
+
// read only while the activity page asks, never on the tick
|
|
110
|
+
const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
|
|
96
111
|
const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
|
|
112
|
+
// the dock: every loaded repo's recent runs, read only while a page asks
|
|
113
|
+
const house = new HouseRuns(() => repos)
|
|
97
114
|
|
|
98
115
|
async function refresh(): Promise<void> {
|
|
99
116
|
const a = await d.gutter.answer()
|
|
@@ -107,7 +124,7 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
107
124
|
|
|
108
125
|
async function tick(): Promise<void> {
|
|
109
126
|
await refresh()
|
|
110
|
-
active = activeSet(rows, d.state.data.active)
|
|
127
|
+
active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
|
|
111
128
|
// the plan's clock: one look per tick, and it starts at most one batch
|
|
112
129
|
d.plan?.tick()
|
|
113
130
|
// a widget render never holds the tick back
|
|
@@ -148,9 +165,19 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
148
165
|
}
|
|
149
166
|
if (path === '/api/home/planned.json') {
|
|
150
167
|
if (repos.length === 0) await refresh()
|
|
151
|
-
const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true }
|
|
168
|
+
const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true, held: d.plan?.held() ?? [] }
|
|
152
169
|
return Response.json(out)
|
|
153
170
|
}
|
|
171
|
+
if (path === '/api/home/activity.json') {
|
|
172
|
+
if (repos.length === 0) await refresh()
|
|
173
|
+
const q = new URL(req.url).searchParams
|
|
174
|
+
return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
|
|
175
|
+
}
|
|
176
|
+
if (path === '/api/house-runs.json') {
|
|
177
|
+
if (repos.length === 0) await refresh()
|
|
178
|
+
const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
|
|
179
|
+
return Response.json(view, { headers: { 'cache-control': 'no-store' } })
|
|
180
|
+
}
|
|
154
181
|
if (path === '/api/home/widgets.json') {
|
|
155
182
|
const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
|
|
156
183
|
return Response.json(out)
|
package/src/home/pm.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// the one constraint a reader breaks first: pm never takes a child down with
|
|
8
8
|
// it. A reload of pm, or pm killed, leaves both running, and the next pm
|
|
9
9
|
// adopts them from their pid records. Only a stop asked of pm stops them.
|
|
10
|
+
import { existsSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
10
12
|
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
13
|
+
import { iconImageType } from '../shipkit-files.ts'
|
|
11
14
|
import { sameOrigin } from '../serve/same-origin.ts'
|
|
12
|
-
import { childLog, childPid } from './home-paths.ts'
|
|
15
|
+
import { childLog, childPid, daemonPort, homePort, pmLog, pmPort } from './home-paths.ts'
|
|
13
16
|
import type { Resources, ResourceView } from './resources.ts'
|
|
14
17
|
import { capLog, Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from './supervise.ts'
|
|
15
18
|
|
|
@@ -21,10 +24,22 @@ export const PM_TICK_MS = 2_000
|
|
|
21
24
|
|
|
22
25
|
export type ChildView = ProcView & { tail: string[] }
|
|
23
26
|
|
|
27
|
+
/** where pm, the daemon and the web listen */
|
|
28
|
+
export type CorePorts = { pm: number; daemon: number; web: number }
|
|
29
|
+
|
|
30
|
+
/** how many log lines each of shipkit's own processes hands the page */
|
|
31
|
+
const CORE_TAIL = 20
|
|
32
|
+
|
|
24
33
|
export type PmAnswer = {
|
|
25
34
|
pm: 'shipkit-pm'
|
|
26
35
|
pid: number
|
|
36
|
+
/** epoch ms this pm started; absent from a pm older than the field */
|
|
37
|
+
since?: number
|
|
27
38
|
version: string
|
|
39
|
+
/** where pm and its children listen; absent from a pm older than the field */
|
|
40
|
+
ports?: CorePorts
|
|
41
|
+
/** the last lines of pm's own log; absent from a pm older than the field */
|
|
42
|
+
tail?: string[]
|
|
28
43
|
children: ChildView[]
|
|
29
44
|
resources: ResourceView[]
|
|
30
45
|
/** the `.shipkit.ts` files that would not load, as pm last read them */
|
|
@@ -35,6 +50,8 @@ export type PmDeps = {
|
|
|
35
50
|
dir: string
|
|
36
51
|
/** the house repo: where the children run */
|
|
37
52
|
root: string
|
|
53
|
+
/** the port pm serves on (default: SHIPKIT_PM_PORT, else 4797) */
|
|
54
|
+
port?: number
|
|
38
55
|
/** the command that runs one role, e.g. [bun, cli.ts, 'daemon'] */
|
|
39
56
|
cmd: (role: Role) => string[]
|
|
40
57
|
/** true once the role serves */
|
|
@@ -50,6 +67,7 @@ const WEB_ANSWER_MS = 200
|
|
|
50
67
|
|
|
51
68
|
export class Pm {
|
|
52
69
|
readonly children: Map<Role, Proc>
|
|
70
|
+
private readonly startedAt = Date.now()
|
|
53
71
|
|
|
54
72
|
constructor(private d: PmDeps) {
|
|
55
73
|
this.children = new Map(ROLES.map((role) => [role, new Proc({
|
|
@@ -90,14 +108,30 @@ export class Pm {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
answer(): PmAnswer {
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
// up: since its pid started, so a pm reload never resets its uptime
|
|
112
|
+
const children = [...this.children.values()].map((p) => {
|
|
113
|
+
const v = p.view()
|
|
114
|
+
return { ...v, since: (v.status === 'up' ? p.startedAt() : null) ?? v.since, tail: tailLines(p.spec.log, CORE_TAIL) }
|
|
115
|
+
})
|
|
116
|
+
const ports = { pm: this.d.port ?? pmPort(), daemon: daemonPort(), web: homePort() }
|
|
117
|
+
return { pm: 'shipkit-pm', pid: process.pid, since: this.startedAt, version: LIB_VERSION, ports, tail: tailLines(pmLog(this.d.dir), CORE_TAIL), children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** the image a global process declares as its icon, and no other file */
|
|
121
|
+
private icon(id: string): Response {
|
|
122
|
+
const d = this.d.resources.get(id)?.decl
|
|
123
|
+
const type = d?.icon == null ? null : iconImageType(d.icon)
|
|
124
|
+
if (d?.icon == null || type == null) return new Response(`no image icon for '${id}'`, { status: 404 })
|
|
125
|
+
const file = join(d.root, d.icon)
|
|
126
|
+
if (!existsSync(file)) return new Response(`${d.repo}/${d.icon} does not exist: the icon of '${id}'`, { status: 404 })
|
|
127
|
+
return new Response(Bun.file(file), { headers: { 'content-type': type, 'cache-control': 'no-cache' } })
|
|
95
128
|
}
|
|
96
129
|
|
|
97
130
|
async handle(req: Request): Promise<Response> {
|
|
98
131
|
const path = new URL(req.url).pathname
|
|
99
132
|
if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
|
|
100
133
|
if (path === '/api/pm/status.json') return Response.json(this.answer())
|
|
134
|
+
if (path.startsWith('/api/pm/icon/') && req.method === 'GET') return this.icon(decodeURIComponent(path.slice('/api/pm/icon/'.length)))
|
|
101
135
|
if (req.method !== 'POST') return new Response('not found', { status: 404 })
|
|
102
136
|
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>
|
|
103
137
|
if (path === '/api/pm/child') {
|
|
@@ -122,7 +156,6 @@ export class Pm {
|
|
|
122
156
|
else if (act === 'restart') report(r.restart())
|
|
123
157
|
else if (act === 'open' || act === 'app') {
|
|
124
158
|
if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
|
|
125
|
-
if (act === 'app' && !r.decl.app) return new Response(`${r.decl.id} does not declare app: true, so it has no app window`, { status: 400 })
|
|
126
159
|
report(r.open(act === 'app' ? 'app' : 'browser'))
|
|
127
160
|
} else if (act === 'takeover') {
|
|
128
161
|
const v = r.takeOverView
|