@stravica-ai/digital-operator-pa 0.1.43 → 0.1.44
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/dist/engineering-pa-cli/src/pa/cli.mjs +4 -2
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-server.md +1 -1
- package/dist/engineering-pa-library-cli/README.md +59 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +41 -1
- package/dist/engineering-pa-library-cli/src/up-merge/github.mjs +103 -0
- package/dist/engineering-pa-library-cli/src/up-merge/scope.mjs +95 -0
- package/dist/engineering-pa-library-cli/src/verbs/up-merge.mjs +290 -0
- package/dist/engineering-pa-workspace/README.md +6 -4
- package/dist/engineering-pa-workspace/docs/DAEMON.md +19 -4
- package/dist/engineering-pa-workspace/docs/status-mon.md +1 -1
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +289 -78
- package/dist/engineering-pa-workspace/src/supervisor.mjs +74 -13
- package/dist/engineering-pa-workspace/src/unit-name.mjs +113 -0
- package/dist/engineering-pa-workspace/src/ws.mjs +17 -5
- package/docs/onboarding-solo.md +1 -1
- package/package.json +1 -1
|
@@ -360,9 +360,11 @@ Usage:
|
|
|
360
360
|
Bind host defaults to loopback (127.0.0.1). Use --host <addr> (or the
|
|
361
361
|
WORKSPACE_HOST env var) to expose the workspace on another interface.
|
|
362
362
|
|
|
363
|
-
When the workspace is supervised by launchd (~/Library/LaunchAgents/com
|
|
364
|
-
or systemd (~/.config/systemd/user
|
|
363
|
+
When the workspace is supervised by launchd (~/Library/LaunchAgents/com.<paName>.workspace.plist)
|
|
364
|
+
or systemd (~/.config/systemd/user/<paName>-workspace.service), \`stop\` is refused and \`restart\`
|
|
365
365
|
delegates to the supervisor — this is the in-place version-pickup path after \`pnpm update\`.
|
|
366
|
+
Unit labels are derived from operator/config/preferences.json:paName; the legacy
|
|
367
|
+
com.dave.workspace / dave-workspace.service is still recognised for pre-0.1.44 installs.
|
|
366
368
|
|
|
367
369
|
Exit codes (tooling standard §6):
|
|
368
370
|
0 ok | 1 usage error | 2 operational failure | 3 transport/IO failure
|
|
@@ -33,7 +33,7 @@ The workspace server hosts `viewer.html` — the PA's canonical surface for long
|
|
|
33
33
|
|
|
34
34
|
## Supervisor-aware lifecycle
|
|
35
35
|
|
|
36
|
-
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com
|
|
36
|
+
When the workspace is supervised by launchd (`~/Library/LaunchAgents/com.<paName>.workspace.plist`) or systemd (`~/.config/systemd/user/<paName>-workspace.service`), `stop` is refused and `restart` delegates to the supervisor. This is the in-place version-pickup path after `pnpm update @stravica-ai/digital-operator-pa`. Unit labels follow the scheduler's `com.{agent}.{id}` convention with `id = workspace` and `agent` resolved from `paName` in `operator/config/preferences.json`; pre-0.1.44 installs are still recognised under the legacy `com.dave.workspace` / `dave-workspace.service` name until you run `install-daemon install --migrate` to rename.
|
|
37
37
|
|
|
38
38
|
## When to use the workspace
|
|
39
39
|
|
|
@@ -27,6 +27,31 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
27
27
|
- `library status [--json]`
|
|
28
28
|
GETs `/status` from the service, plus `git rev-list HEAD..origin/main --count`
|
|
29
29
|
per tier clone.
|
|
30
|
+
- `library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]`
|
|
31
|
+
Scheduled auto-merger for library up-path PRs. Scans configured library
|
|
32
|
+
repos for open PRs from branch `library-sync-up/<device>` and merges each
|
|
33
|
+
one whose diff is confined to the paths `<device>` owns per
|
|
34
|
+
`operator/registry/repos.json`. Anything else is a refusal: the PR is
|
|
35
|
+
left open with a one-time comment (idempotent via an HTML marker) and no
|
|
36
|
+
merge is ever attempted. Writes one JSONL line per PR per pass to
|
|
37
|
+
`library.upMerge.eventsLog` (default `.cache/library-up-merger/events.jsonl`).
|
|
38
|
+
Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
|
|
39
|
+
no ingest touch). Config in the `library.upMerge` stanza; see below.
|
|
40
|
+
|
|
41
|
+
### Scope rule (verbatim)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
A PR is eligible when both:
|
|
45
|
+
(1) its head branch matches "library-sync-up/<device>"
|
|
46
|
+
(2) every changed file's repo-relative path falls under a path <device>
|
|
47
|
+
owns for that library repo per operator/registry/repos.json:
|
|
48
|
+
for each mount M where M.upPath === true and M.upPathOwner === <device>:
|
|
49
|
+
allowed prefix = M.from + include, for each include in M.upPathInclude
|
|
50
|
+
a file matches when its path equals a prefix (single-file include)
|
|
51
|
+
or starts with prefix + "/" (directory include).
|
|
52
|
+
Any file outside the device's allowed prefixes is a refusal - the PR is
|
|
53
|
+
left open, a one-time comment is posted, and no merge is ever attempted.
|
|
54
|
+
```
|
|
30
55
|
|
|
31
56
|
## Configuration
|
|
32
57
|
|
|
@@ -45,6 +70,40 @@ deliberately not a shim for the pre-remote library sweep.
|
|
|
45
70
|
|
|
46
71
|
- `LIBRARY_PULL_CLONES_ROOT` env — overrides the pull-clones root at runtime.
|
|
47
72
|
|
|
73
|
+
- `library.upMerge` stanza in preferences (used by `library up-merge` only):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"library": {
|
|
78
|
+
"upMerge": {
|
|
79
|
+
"repos": ["Stravica/stravica-library-wsd"],
|
|
80
|
+
"registryPath": "operator/registry/repos.json",
|
|
81
|
+
"eventsLog": ".cache/library-up-merger/events.jsonl",
|
|
82
|
+
"mergeMethod": "merge",
|
|
83
|
+
"branchPrefix": "library-sync-up/",
|
|
84
|
+
"deleteBranchOnMerge": true,
|
|
85
|
+
"ghBin": "gh"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every field has a safe default; `repos` defaults to `[]` (no repos scanned)
|
|
92
|
+
and can be overridden per invocation with `--repo <owner/name>` (repeatable).
|
|
93
|
+
|
|
94
|
+
### Migrating from the HQ `library-up-merger` tool
|
|
95
|
+
|
|
96
|
+
`library up-merge` is a behaviour-identical promotion of the HQ tool at
|
|
97
|
+
`operator/extensions/tools/library-up-merger/`. The scheduled command
|
|
98
|
+
changes from
|
|
99
|
+
`node operator/extensions/tools/library-up-merger/bin/library-up-merger`
|
|
100
|
+
to `pnpm exec library up-merge`. The scope rule, refusal marker, events
|
|
101
|
+
log path, and default merge method are unchanged; the HQ tool's
|
|
102
|
+
`config.json` maps 1:1 to the `library.upMerge` stanza above (`repos`,
|
|
103
|
+
`registryPath`, `eventsLog`, `mergeMethod`, `branchPrefix`,
|
|
104
|
+
`deleteBranchOnMerge`). Repoint the schedule at the new command and
|
|
105
|
+
retire the HQ tool.
|
|
106
|
+
|
|
48
107
|
Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
|
|
49
108
|
no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
|
|
50
109
|
interim was removed in the same pass.
|
|
@@ -22,6 +22,7 @@ import { runGet } from './verbs/get.mjs';
|
|
|
22
22
|
import { runSearch } from './verbs/search.mjs';
|
|
23
23
|
import { runStatus } from './verbs/status.mjs';
|
|
24
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
25
|
+
import { runUpMerge } from './verbs/up-merge.mjs';
|
|
25
26
|
|
|
26
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
|
|
@@ -52,13 +53,15 @@ Usage:
|
|
|
52
53
|
library search <query> [--files-only]
|
|
53
54
|
library status [--json]
|
|
54
55
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
56
|
+
library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
|
|
55
57
|
library -h | --help
|
|
56
58
|
library -v | --version
|
|
57
59
|
|
|
58
60
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
59
61
|
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
60
62
|
one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
|
|
61
|
-
with a clean not-applicable message.
|
|
63
|
+
with a clean not-applicable message. 'library up-merge' is a GitHub-side
|
|
64
|
+
maintenance verb and runs in both SIMPLE and REMOTE mode.
|
|
62
65
|
|
|
63
66
|
--help and --version never touch config or the vault, so they always work
|
|
64
67
|
from a directory with no operator config and no vault installed.
|
|
@@ -102,6 +105,18 @@ export async function main(argv, seams = {}) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
if (config.mode === 'simple') {
|
|
108
|
+
// `library up-merge` is a GitHub-side maintenance verb: it reads
|
|
109
|
+
// library.upMerge from preferences and hits gh, never the ingest
|
|
110
|
+
// service. It runs the same in SIMPLE and REMOTE mode.
|
|
111
|
+
if (verb === 'up-merge') {
|
|
112
|
+
const ctxUp = { config, log, err };
|
|
113
|
+
try {
|
|
114
|
+
return await runUpMerge({ ...ctxUp, args: parseUpMerge(rest) });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
err(`[library up-merge] ${e.message ?? e}`);
|
|
117
|
+
return typeof e.exitCode === 'number' ? e.exitCode : 2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
105
120
|
// `library status` is the one verb useful in SIMPLE mode too: a plain
|
|
106
121
|
// one-line mode summary that tells the operator the CLI is installed
|
|
107
122
|
// and the remote path is intentionally not wired. No vault touch,
|
|
@@ -141,6 +156,7 @@ export async function main(argv, seams = {}) {
|
|
|
141
156
|
case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
|
|
142
157
|
case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
|
|
143
158
|
case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
|
|
159
|
+
case 'up-merge': return await runUpMerge({ ...ctx, args: parseUpMerge(rest) });
|
|
144
160
|
default:
|
|
145
161
|
err(`[library] unknown verb: ${verb}`);
|
|
146
162
|
log(USAGE);
|
|
@@ -195,6 +211,30 @@ function parseStatus(argv) {
|
|
|
195
211
|
});
|
|
196
212
|
return { json: values.json };
|
|
197
213
|
}
|
|
214
|
+
function parseUpMerge(argv) {
|
|
215
|
+
const { values } = parseArgs({
|
|
216
|
+
args: argv,
|
|
217
|
+
strict: true,
|
|
218
|
+
allowPositionals: false,
|
|
219
|
+
options: {
|
|
220
|
+
repo: { type: 'string', multiple: true },
|
|
221
|
+
pr: { type: 'string' },
|
|
222
|
+
'dry-run': { type: 'boolean', default: false },
|
|
223
|
+
once: { type: 'boolean', default: false },
|
|
224
|
+
verbose: { type: 'boolean', default: false },
|
|
225
|
+
json: { type: 'boolean', default: false },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const pr = values.pr ? parseInt(values.pr, 10) : undefined;
|
|
229
|
+
return {
|
|
230
|
+
repos: values.repo,
|
|
231
|
+
pr,
|
|
232
|
+
dryRun: values['dry-run'],
|
|
233
|
+
once: values.once,
|
|
234
|
+
verbose: values.verbose,
|
|
235
|
+
json: values.json,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
198
238
|
function parseRefreshClones(argv) {
|
|
199
239
|
const { values } = parseArgs({
|
|
200
240
|
args: argv,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Thin wrappers around the gh CLI, factored so tests can inject a runner
|
|
2
|
+
// and never spawn a real gh. Auth is handled by gh from the host's own
|
|
3
|
+
// login (TheFoot on smarthome01 for the scheduled path today).
|
|
4
|
+
//
|
|
5
|
+
// The runner signature is deliberately narrow: (cmd, args, { input }) ->
|
|
6
|
+
// Promise<{ code, stdout, stderr }>. Every wrapper takes an optional
|
|
7
|
+
// `runner` seam that defaults to the module-local `defaultRunner`, which
|
|
8
|
+
// is a plain spawn. Tests build a runner that inspects args and returns
|
|
9
|
+
// canned stdout/stderr, so no gh process is ever invoked.
|
|
10
|
+
//
|
|
11
|
+
// Ported from operator/extensions/tools/library-up-merger/src/github.mjs
|
|
12
|
+
// and factored for injection (w-2026-09-16-dave-014, d-2026-09-17-013).
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {(cmd: string, args: string[], opts?: { input?: string }) => Promise<{ code: number, stdout: string, stderr: string }>} Runner
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {Runner} */
|
|
21
|
+
export function defaultRunner(cmd, args, { input } = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
27
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => {
|
|
30
|
+
resolve({ code, stdout, stderr });
|
|
31
|
+
});
|
|
32
|
+
if (input != null) child.stdin.end(input);
|
|
33
|
+
else child.stdin.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function ghJson(args, { runner = defaultRunner, ghBin = 'gh' } = {}) {
|
|
38
|
+
const res = await runner(ghBin, args);
|
|
39
|
+
if (res.code !== 0) {
|
|
40
|
+
const err = new Error(`gh ${args.join(' ')} exited ${res.code}: ${res.stderr.trim()}`);
|
|
41
|
+
err.stderr = res.stderr;
|
|
42
|
+
err.stdout = res.stdout;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
const text = res.stdout.trim();
|
|
46
|
+
if (!text) return null;
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listOpenPrs(repoSlug, opts = {}) {
|
|
51
|
+
const data = await ghJson([
|
|
52
|
+
'pr', 'list', '--repo', repoSlug, '--state', 'open',
|
|
53
|
+
'--json', 'number,headRefName,author,title,url,isDraft',
|
|
54
|
+
'--limit', '100',
|
|
55
|
+
], opts);
|
|
56
|
+
return data || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listChangedFiles(repoSlug, number, opts = {}) {
|
|
60
|
+
const data = await ghJson([
|
|
61
|
+
'pr', 'view', String(number), '--repo', repoSlug,
|
|
62
|
+
'--json', 'files',
|
|
63
|
+
], opts);
|
|
64
|
+
return (data && Array.isArray(data.files) ? data.files : []).map((f) => f.path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function mergePr(repoSlug, number, {
|
|
68
|
+
method = 'merge', deleteBranch = true, runner = defaultRunner, ghBin = 'gh',
|
|
69
|
+
} = {}) {
|
|
70
|
+
const args = ['pr', 'merge', String(number), '--repo', repoSlug, `--${method}`];
|
|
71
|
+
if (deleteBranch) args.push('--delete-branch');
|
|
72
|
+
const res = await runner(ghBin, args);
|
|
73
|
+
return {
|
|
74
|
+
ok: res.code === 0,
|
|
75
|
+
code: res.code,
|
|
76
|
+
stdout: res.stdout.trim(),
|
|
77
|
+
stderr: res.stderr.trim(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function commentOnPr(repoSlug, number, body, {
|
|
82
|
+
runner = defaultRunner, ghBin = 'gh',
|
|
83
|
+
} = {}) {
|
|
84
|
+
const res = await runner(
|
|
85
|
+
ghBin,
|
|
86
|
+
['pr', 'comment', String(number), '--repo', repoSlug, '--body-file', '-'],
|
|
87
|
+
{ input: body },
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
ok: res.code === 0,
|
|
91
|
+
code: res.code,
|
|
92
|
+
stdout: res.stdout.trim(),
|
|
93
|
+
stderr: res.stderr.trim(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function listExistingComments(repoSlug, number, opts = {}) {
|
|
98
|
+
const data = await ghJson(
|
|
99
|
+
['pr', 'view', String(number), '--repo', repoSlug, '--json', 'comments'],
|
|
100
|
+
opts,
|
|
101
|
+
);
|
|
102
|
+
return (data && Array.isArray(data.comments) ? data.comments : []).map((c) => c.body || '');
|
|
103
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Scope-rule computation for library up-path PRs.
|
|
2
|
+
//
|
|
3
|
+
// A PR from head branch `<branchPrefix><device>` on library repo <repoSlug> may be merged
|
|
4
|
+
// when and only when EVERY changed file's repo-relative path falls under one of the
|
|
5
|
+
// device's owned up-paths for that library repo, computed from operator/registry/repos.json:
|
|
6
|
+
//
|
|
7
|
+
// For each mount M in repos[<library repo id>].mounts where M.upPath === true
|
|
8
|
+
// and M.upPathOwner === <device>:
|
|
9
|
+
// allowedPrefix = M.from + include, for each include in (M.upPathInclude || [])
|
|
10
|
+
// A file matches when its path === allowedPrefix (single-file include)
|
|
11
|
+
// or starts with allowedPrefix + "/" (directory include).
|
|
12
|
+
//
|
|
13
|
+
// A device with no owned up-paths on the repo, or a file matching none of its allowed
|
|
14
|
+
// prefixes, is a refusal. Refusals never merge.
|
|
15
|
+
//
|
|
16
|
+
// Ported verbatim (behaviour) from operator/extensions/tools/library-up-merger/src/scope.mjs
|
|
17
|
+
// on the promotion to core (w-2026-09-16-dave-014, d-2026-09-17-013). The HQ tool was the
|
|
18
|
+
// reference implementation; this module is the canonical home from 0.1.44 onward.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} registry parsed operator/registry/repos.json
|
|
22
|
+
* @param {string} repoSlug e.g. "Stravica/stravica-library-wsd"
|
|
23
|
+
* @returns {string|null} matching registry key, or null
|
|
24
|
+
*/
|
|
25
|
+
export function repoIdForSlug(registry, repoSlug) {
|
|
26
|
+
const wanted = repoSlug.toLowerCase();
|
|
27
|
+
for (const [id, cfg] of Object.entries(registry.repos || {})) {
|
|
28
|
+
const remote = (cfg.gitRemote || '').toLowerCase();
|
|
29
|
+
// git@github.com:Owner/name.git -> Owner/name
|
|
30
|
+
const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
31
|
+
if (m && m[1] === wanted) return id;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute the sorted, de-duplicated list of allowed repo-relative path prefixes for a
|
|
38
|
+
* device on a library repo. Each entry is either an exact file path or a directory path
|
|
39
|
+
* (no trailing slash) - the matcher below appends "/" for directory matching.
|
|
40
|
+
*/
|
|
41
|
+
export function allowedPathsForDevice(registry, repoId, device) {
|
|
42
|
+
const repo = (registry.repos || {})[repoId];
|
|
43
|
+
if (!repo || !Array.isArray(repo.mounts)) return [];
|
|
44
|
+
const out = new Set();
|
|
45
|
+
for (const mount of repo.mounts) {
|
|
46
|
+
if (!mount || mount.upPath !== true) continue;
|
|
47
|
+
if (mount.upPathOwner !== device) continue;
|
|
48
|
+
const fromPrefix = String(mount.from || '').replace(/\/+$/, '');
|
|
49
|
+
const includes = Array.isArray(mount.upPathInclude) ? mount.upPathInclude : [];
|
|
50
|
+
for (const inc of includes) {
|
|
51
|
+
const clean = String(inc).replace(/\/+$/, '');
|
|
52
|
+
if (!clean) continue;
|
|
53
|
+
out.add(fromPrefix ? `${fromPrefix}/${clean}` : clean);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...out].sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does `filePath` (repo-relative) fall under one of the `allowed` entries?
|
|
61
|
+
* An entry matches when the path equals it, or starts with `entry + "/"`.
|
|
62
|
+
*/
|
|
63
|
+
export function fileAllowed(filePath, allowed) {
|
|
64
|
+
const f = String(filePath || '').replace(/^\/+/, '');
|
|
65
|
+
for (const entry of allowed) {
|
|
66
|
+
if (f === entry) return true;
|
|
67
|
+
if (f.startsWith(`${entry}/`)) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify a PR's file list against a device's allowed prefixes.
|
|
74
|
+
* @returns {{ ok: boolean, refused: string[], allowed: string[] }}
|
|
75
|
+
*/
|
|
76
|
+
export function classifyFiles(files, allowed) {
|
|
77
|
+
const refused = [];
|
|
78
|
+
const allowedFiles = [];
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
if (fileAllowed(f, allowed)) allowedFiles.push(f);
|
|
81
|
+
else refused.push(f);
|
|
82
|
+
}
|
|
83
|
+
return { ok: refused.length === 0, refused, allowed: allowedFiles };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a device out of a head-ref of the form `<branchPrefix><device>`.
|
|
88
|
+
* Returns null if the branch does not match.
|
|
89
|
+
*/
|
|
90
|
+
export function deviceFromBranch(branch, branchPrefix) {
|
|
91
|
+
if (!branch || !branch.startsWith(branchPrefix)) return null;
|
|
92
|
+
const rest = branch.slice(branchPrefix.length);
|
|
93
|
+
if (!rest || rest.includes('/')) return null;
|
|
94
|
+
return rest;
|
|
95
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// verbs/up-merge.mjs — `library up-merge`
|
|
2
|
+
//
|
|
3
|
+
// Scheduled auto-merger for library up-path PRs.
|
|
4
|
+
//
|
|
5
|
+
// Scans configured library repos for open PRs from branch
|
|
6
|
+
// `library-sync-up/<device>`, and merges each one whose diff is confined
|
|
7
|
+
// to the paths <device> owns per operator/registry/repos.json. Anything
|
|
8
|
+
// else is a refusal, never a merge, and gets a one-time comment carrying
|
|
9
|
+
// the REFUSAL_MARKER (idempotent).
|
|
10
|
+
//
|
|
11
|
+
// Promoted from operator/extensions/tools/library-up-merger/src/cli.mjs
|
|
12
|
+
// (HQ tool, w-2026-09-16-dave-014, d-2026-09-17-013). Config now lives
|
|
13
|
+
// in the merged operator preferences under `library.upMerge`; defaults
|
|
14
|
+
// mirror the HQ tool's config.json.
|
|
15
|
+
//
|
|
16
|
+
// Mode note: `up-merge` is a GitHub-side maintenance verb and runs the
|
|
17
|
+
// same way regardless of `library.mode`. It reads `library.upMerge`
|
|
18
|
+
// from the merged preferences and does not touch the remote-library
|
|
19
|
+
// ingest surface. The main cli.mjs short-circuits the SIMPLE-mode
|
|
20
|
+
// refusal for this verb.
|
|
21
|
+
|
|
22
|
+
import { readFile, mkdir, appendFile } from 'node:fs/promises';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
repoIdForSlug,
|
|
27
|
+
allowedPathsForDevice,
|
|
28
|
+
classifyFiles,
|
|
29
|
+
deviceFromBranch,
|
|
30
|
+
} from '../up-merge/scope.mjs';
|
|
31
|
+
import {
|
|
32
|
+
listOpenPrs,
|
|
33
|
+
listChangedFiles,
|
|
34
|
+
mergePr,
|
|
35
|
+
commentOnPr,
|
|
36
|
+
listExistingComments,
|
|
37
|
+
defaultRunner,
|
|
38
|
+
} from '../up-merge/github.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default `library.upMerge` stanza. Mirrors the HQ tool's config.json so
|
|
42
|
+
* the estate keeps its current behaviour with no preferences change.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_UP_MERGE_CONFIG = Object.freeze({
|
|
45
|
+
repos: [],
|
|
46
|
+
registryPath: 'operator/registry/repos.json',
|
|
47
|
+
eventsLog: '.cache/library-up-merger/events.jsonl',
|
|
48
|
+
mergeMethod: 'merge',
|
|
49
|
+
branchPrefix: 'library-sync-up/',
|
|
50
|
+
deleteBranchOnMerge: true,
|
|
51
|
+
ghBin: 'gh',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const REFUSAL_MARKER = '<!-- library-up-merger:refusal -->';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the `library.upMerge` stanza from merged preferences, layering
|
|
58
|
+
* on top of {@link DEFAULT_UP_MERGE_CONFIG}. Missing block → all defaults.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} preferences merged preferences object
|
|
61
|
+
* @returns {typeof DEFAULT_UP_MERGE_CONFIG}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveUpMergeConfig(preferences) {
|
|
64
|
+
const raw = preferences && typeof preferences === 'object'
|
|
65
|
+
? preferences.library && preferences.library.upMerge
|
|
66
|
+
: null;
|
|
67
|
+
const block = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
68
|
+
return {
|
|
69
|
+
...DEFAULT_UP_MERGE_CONFIG,
|
|
70
|
+
...block,
|
|
71
|
+
repos: Array.isArray(block.repos) ? block.repos : DEFAULT_UP_MERGE_CONFIG.repos,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function refusalBody(device, allowed, refused) {
|
|
76
|
+
const allowedList = allowed.length
|
|
77
|
+
? allowed.map((p) => ` - ${p}`).join('\n')
|
|
78
|
+
: ' (none - device has no owned up-paths on this repo)';
|
|
79
|
+
const refusedList = refused.map((p) => ` - ${p}`).join('\n');
|
|
80
|
+
return [
|
|
81
|
+
REFUSAL_MARKER,
|
|
82
|
+
'library up-merge refuses to merge this PR: it touches paths outside the up-path scope for the device on its head branch.',
|
|
83
|
+
'',
|
|
84
|
+
`Device (from branch): \`${device}\``,
|
|
85
|
+
'',
|
|
86
|
+
'Allowed prefixes for this device on this repo (from operator/registry/repos.json):',
|
|
87
|
+
allowedList,
|
|
88
|
+
'',
|
|
89
|
+
'Refused files:',
|
|
90
|
+
refusedList,
|
|
91
|
+
'',
|
|
92
|
+
'Split the out-of-scope files into a separate PR owned by whichever device owns them, or narrow this PR to the allowed prefixes.',
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function appendEvent(logPath, event) {
|
|
97
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
98
|
+
await appendFile(logPath, `${JSON.stringify(event)}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function loadJson(path) {
|
|
102
|
+
const raw = await readFile(path, 'utf8');
|
|
103
|
+
return JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {object} ctx
|
|
108
|
+
* @param {object} ctx.config loaded library config (carries repoRoot + preferences)
|
|
109
|
+
* @param {object} [ctx.args] { repos?: string[], pr?: number, dryRun?: boolean, once?: boolean, verbose?: boolean, json?: boolean }
|
|
110
|
+
* @param {(...m:unknown[])=>void} [ctx.log]
|
|
111
|
+
* @param {(...m:unknown[])=>void} [ctx.err]
|
|
112
|
+
* @param {import('../up-merge/github.mjs').Runner} [ctx.ghRunner] gh spawn seam (tests inject)
|
|
113
|
+
* @returns {Promise<number>} exit code (0 ok, 2 errors)
|
|
114
|
+
*/
|
|
115
|
+
export async function runUpMerge({
|
|
116
|
+
config,
|
|
117
|
+
args = {},
|
|
118
|
+
log = defaultLog,
|
|
119
|
+
err = defaultErr,
|
|
120
|
+
ghRunner = defaultRunner,
|
|
121
|
+
}) {
|
|
122
|
+
const upMerge = resolveUpMergeConfig(config.preferences);
|
|
123
|
+
const estateRoot = config.repoRoot;
|
|
124
|
+
|
|
125
|
+
const registryPath = resolve(estateRoot, upMerge.registryPath);
|
|
126
|
+
let registry;
|
|
127
|
+
try {
|
|
128
|
+
registry = await loadJson(registryPath);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
err(`[library up-merge] failed to read registry ${registryPath}: ${e.message ?? e}`);
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const repos = args.repos && args.repos.length ? args.repos : upMerge.repos;
|
|
135
|
+
if (!repos.length) {
|
|
136
|
+
err('[library up-merge] no repos configured; set library.upMerge.repos in preferences or pass --repo');
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
if (args.pr && (!args.repos || args.repos.length === 0)) {
|
|
140
|
+
err('[library up-merge] --pr requires --repo');
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const eventsLog = resolve(estateRoot, upMerge.eventsLog);
|
|
145
|
+
const runAt = new Date().toISOString();
|
|
146
|
+
const ghOpts = { runner: ghRunner, ghBin: upMerge.ghBin };
|
|
147
|
+
|
|
148
|
+
const summary = {
|
|
149
|
+
runAt,
|
|
150
|
+
dryRun: !!args.dryRun,
|
|
151
|
+
repos: [],
|
|
152
|
+
merged: 0,
|
|
153
|
+
refused: 0,
|
|
154
|
+
skipped: 0,
|
|
155
|
+
errors: 0,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const repoSlug of repos) {
|
|
159
|
+
const repoId = repoIdForSlug(registry, repoSlug);
|
|
160
|
+
if (!repoId) {
|
|
161
|
+
const line = { runAt, repo: repoSlug, verdict: 'error', reason: 'repo-not-in-registry' };
|
|
162
|
+
await appendEvent(eventsLog, line);
|
|
163
|
+
log(`error ${repoSlug}: not in registry`);
|
|
164
|
+
summary.errors += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let prs;
|
|
169
|
+
try {
|
|
170
|
+
prs = await listOpenPrs(repoSlug, ghOpts);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
const line = {
|
|
173
|
+
runAt, repo: repoSlug, verdict: 'error',
|
|
174
|
+
reason: 'list-prs-failed', detail: String(e.message ?? e),
|
|
175
|
+
};
|
|
176
|
+
await appendEvent(eventsLog, line);
|
|
177
|
+
log(`error ${repoSlug}: list PRs failed - ${e.message ?? e}`);
|
|
178
|
+
summary.errors += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const repoStats = { repo: repoSlug, considered: prs.length, merged: 0, refused: 0, skipped: 0 };
|
|
183
|
+
|
|
184
|
+
for (const pr of prs) {
|
|
185
|
+
if (args.pr && pr.number !== args.pr) continue;
|
|
186
|
+
const device = deviceFromBranch(pr.headRefName, upMerge.branchPrefix);
|
|
187
|
+
if (!device) {
|
|
188
|
+
if (args.verbose) log(`skip ${repoSlug}#${pr.number}: head ${pr.headRefName} not an up-path branch`);
|
|
189
|
+
repoStats.skipped += 1;
|
|
190
|
+
summary.skipped += 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (pr.isDraft) {
|
|
194
|
+
if (args.verbose) log(`skip ${repoSlug}#${pr.number}: draft`);
|
|
195
|
+
repoStats.skipped += 1;
|
|
196
|
+
summary.skipped += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const allowed = allowedPathsForDevice(registry, repoId, device);
|
|
201
|
+
let files;
|
|
202
|
+
try {
|
|
203
|
+
files = await listChangedFiles(repoSlug, pr.number, ghOpts);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
await appendEvent(eventsLog, {
|
|
206
|
+
runAt, repo: repoSlug, pr: pr.number, device, verdict: 'error',
|
|
207
|
+
reason: 'list-files-failed', detail: String(e.message ?? e),
|
|
208
|
+
});
|
|
209
|
+
log(`error ${repoSlug}#${pr.number}: list files failed - ${e.message ?? e}`);
|
|
210
|
+
summary.errors += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const cls = classifyFiles(files, allowed);
|
|
214
|
+
|
|
215
|
+
if (!cls.ok) {
|
|
216
|
+
let commented = false;
|
|
217
|
+
if (!args.dryRun) {
|
|
218
|
+
try {
|
|
219
|
+
const existing = await listExistingComments(repoSlug, pr.number, ghOpts);
|
|
220
|
+
const alreadyComplained = existing.some((body) => (body || '').includes(REFUSAL_MARKER));
|
|
221
|
+
if (!alreadyComplained) {
|
|
222
|
+
const res = await commentOnPr(
|
|
223
|
+
repoSlug, pr.number, refusalBody(device, allowed, cls.refused), ghOpts,
|
|
224
|
+
);
|
|
225
|
+
commented = res.ok;
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
err(`comment ${repoSlug}#${pr.number} failed: ${e.message ?? e}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await appendEvent(eventsLog, {
|
|
232
|
+
runAt, repo: repoSlug, pr: pr.number, device, verdict: 'refused',
|
|
233
|
+
refusedFiles: cls.refused, allowedFiles: cls.allowed, allowedPrefixes: allowed,
|
|
234
|
+
commented, dryRun: !!args.dryRun,
|
|
235
|
+
});
|
|
236
|
+
log(`refused ${repoSlug}#${pr.number} (${device}): ${cls.refused.length} out-of-scope file(s) - ${cls.refused.slice(0, 3).join(', ')}${cls.refused.length > 3 ? ', ...' : ''}`);
|
|
237
|
+
repoStats.refused += 1;
|
|
238
|
+
summary.refused += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (args.dryRun) {
|
|
243
|
+
await appendEvent(eventsLog, {
|
|
244
|
+
runAt, repo: repoSlug, pr: pr.number, device, verdict: 'would-merge',
|
|
245
|
+
files, allowedPrefixes: allowed, dryRun: true,
|
|
246
|
+
});
|
|
247
|
+
log(`would-merge ${repoSlug}#${pr.number} (${device}): ${files.length} file(s) in scope`);
|
|
248
|
+
repoStats.merged += 1;
|
|
249
|
+
summary.merged += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const mergeRes = await mergePr(repoSlug, pr.number, {
|
|
254
|
+
method: upMerge.mergeMethod,
|
|
255
|
+
deleteBranch: upMerge.deleteBranchOnMerge !== false,
|
|
256
|
+
...ghOpts,
|
|
257
|
+
});
|
|
258
|
+
if (mergeRes.ok) {
|
|
259
|
+
await appendEvent(eventsLog, {
|
|
260
|
+
runAt, repo: repoSlug, pr: pr.number, device, verdict: 'merged',
|
|
261
|
+
files, allowedPrefixes: allowed,
|
|
262
|
+
});
|
|
263
|
+
log(`merged ${repoSlug}#${pr.number} (${device}): ${files.length} file(s) in scope`);
|
|
264
|
+
repoStats.merged += 1;
|
|
265
|
+
summary.merged += 1;
|
|
266
|
+
} else {
|
|
267
|
+
await appendEvent(eventsLog, {
|
|
268
|
+
runAt, repo: repoSlug, pr: pr.number, device, verdict: 'merge-failed',
|
|
269
|
+
exitCode: mergeRes.code, stderr: mergeRes.stderr, files,
|
|
270
|
+
});
|
|
271
|
+
log(`merge-failed ${repoSlug}#${pr.number} (${device}): exit ${mergeRes.code} - ${(mergeRes.stderr || '').split('\n')[0]}`);
|
|
272
|
+
summary.errors += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
summary.repos.push(repoStats);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (args.json) {
|
|
280
|
+
log(JSON.stringify(summary, null, 2));
|
|
281
|
+
} else {
|
|
282
|
+
log(`summary ${JSON.stringify(summary)}`);
|
|
283
|
+
}
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function defaultLog(...m) { process.stdout.write(m.join(' ') + '\n'); }
|
|
288
|
+
function defaultErr(...m) { process.stderr.write(m.join(' ') + '\n'); }
|
|
289
|
+
|
|
290
|
+
export { REFUSAL_MARKER };
|