@edgehero/pi-dispatch-admin 0.4.0 → 0.5.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/README.md +27 -7
- package/dist/index.mjs +231 -26
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
Every trigger produces the same job, through the same path: one queue, one container, one budget.
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
CLI
|
|
16
|
+
CLI · cron · label / comment / PR on GitHub, GitLab, Forgejo or Azure DevOps
|
|
17
17
|
│ enqueue
|
|
18
18
|
▼
|
|
19
19
|
Valkey + BullMQ durable queue: survives reboots, absorbs bursts
|
|
@@ -23,14 +23,32 @@ Every trigger produces the same job, through the same path: one queue, one conta
|
|
|
23
23
|
per-job turn budget?
|
|
24
24
|
│ yes
|
|
25
25
|
▼
|
|
26
|
-
docker run --rm one ephemeral container per job:
|
|
27
|
-
|
|
26
|
+
docker run --rm one ephemeral container per job: --cap-drop=ALL,
|
|
27
|
+
--security-opt no-new-privileges, memory/CPU/pids
|
|
28
|
+
limits, /job mounted read-only
|
|
28
29
|
│
|
|
29
30
|
▼
|
|
30
|
-
pi + your .pi/skills edits your code in place, opens a PR, comments back
|
|
31
|
+
pi + your .pi/skills edits your code in place, opens a PR or MR, comments back
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
The container is the boundary (pi's missing permission system, enforced by Docker). Spend is checked before a container starts, so a runaway or a junk trigger costs a refusal, not a surprise bill. The job image is yours to shape, per deployment or per trigger, and it ships Playwright and Chromium so a flow can build a frontend, screenshot it, and iterate on the render.
|
|
34
|
+
The container is the boundary (pi's missing permission system, enforced by Docker). Those isolation flags are built by the worker's own `docker run` argv, so nothing an image contains can weaken them; the non-root user is a property of the **image**, which is why an image has to meet the conformance checklist in [`docs/job-image.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/job-image.md). Spend is checked before a container starts, so a runaway or a junk trigger costs a refusal, not a surprise bill. The job image is yours to shape, per deployment or per trigger, and it ships Playwright and Chromium so a flow can build a frontend, screenshot it, and iterate on the render.
|
|
35
|
+
|
|
36
|
+
## Triggers: what starts a job
|
|
37
|
+
|
|
38
|
+
Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live by the worker (cron) and the receiver (forge webhooks), and editable from the console. **`on` is what fires it; `run` is the skill it runs.**
|
|
39
|
+
|
|
40
|
+
| `on.type` | Fires on | What narrows it | What the agent gets as its task |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `cron` | your schedule | nothing: a schedule is its own condition | the `task` written in the file |
|
|
43
|
+
| `label` | a label on an **issue** (or an Azure work item), never a pull request | a label predicate: `any`, `all`, or `none` (which can suppress a fire, never cause one) | the issue title and body |
|
|
44
|
+
| `comment` | a comment containing your phrase, for example `@pi` | the phrase, and one comment trigger per forge | the comment body, plus the issue title and body |
|
|
45
|
+
| `pull_request` | a PR or MR event | `action`, in your forge's own words, plus the same label predicate | the PR title and body |
|
|
46
|
+
|
|
47
|
+
Four forges: GitHub, GitLab, Forgejo (and Gitea), Azure DevOps. **Who may fire a trigger is your forge's decision, not this service's**: on GitHub the label *is* the approval, because only collaborators can apply one, while GitLab, Forgejo and Azure resolve the actor's permission through their APIs. Each forge's action vocabulary is validated when the file loads, so a word from the wrong forge is refused instead of silently never matching.
|
|
48
|
+
|
|
49
|
+
**Flows, and workflows.** `run.flow` names a skill committed to the target repo at `.pi/skills/<flow>/SKILL.md`, read from the **default branch**, so the repo owns the prompt and merging it is the repo's consent. A skill may call other skills, which is already a workflow. For typed multi-stage ones, a pi extension such as `@juicesharp/rpiv-workflow` can be staged into the deployment: pinned to an exact version, installed on your host (never at job time, since jobs run offline), loaded in every container, and declinable per trigger.
|
|
50
|
+
|
|
51
|
+
**How a workflow gets triggered, in one line:** `label / comment / PR / cron` fires **one job in one container**, that job runs `run.flow`, and the flow drives whatever stages follow. Four consequences worth knowing before you build on it. A trigger names a **flow, never a workflow**, so which stages run is decided inside the job by that skill. A job is **not an interactive session** (the container hands pi one assembled prompt and reads the exit line), so a workflow extension's slash command has nobody to type it: in a job the flow's instructions drive the workflow, or a small extension you also staged calls its API from a lifecycle hook. One trigger is **one job, one budget slot and one turn budget**, so ten stages share the same `PI_MAX_TURNS` and per-job token budget, and exhausting either ends the job as a policy refusal that is never retried. And whether the workflow's own state survives depends on the trigger kind: a cron or CLI job has your folder mounted read-write so state persists between runs, while a forge job gets a fresh clone that is discarded with the container. Full reference: [`docs/workflows.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/workflows.md).
|
|
34
52
|
|
|
35
53
|
## The console: `/dispatch`
|
|
36
54
|
|
|
@@ -49,7 +67,7 @@ One command puts a live TUI over the whole deployment:
|
|
|
49
67
|
|
|
50
68
|
- **Triggers, editable live.** cron, label, comment and pull_request triggers with colored drill-ins showing what fires each one, what it runs, and its trust model. Added, edited and deleted without a restart. Triggers that run third-party code or a custom image are badged; opting in or out of either stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
|
|
51
69
|
- **Quiet hours.** Scheduled pause windows per folder or repo: defer runs between certain times, timezone-aware, and resume automatically. Deferred, never dropped, at zero budget cost.
|
|
52
|
-
- **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows,
|
|
70
|
+
- **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
|
|
53
71
|
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
|
|
54
72
|
|
|
55
73
|
## Install
|
|
@@ -58,10 +76,12 @@ One command puts a live TUI over the whole deployment:
|
|
|
58
76
|
pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
|
|
59
77
|
```
|
|
60
78
|
|
|
61
|
-
**
|
|
79
|
+
**This is the default way to set up pi-dispatch.** With nothing configured, `/dispatch` takes you straight into guided setup, in this order: an opening choice, a deployment folder, a Docker check with per-OS pointers if it is missing, a consented npm install of the pinned runtime (Docker is checked **first** on purpose, so no bandwidth is spent on a host where `up` cannot work anyway), `pi-dispatch up` running its own prompts in your terminal, the deployment pointer that lets `/dispatch` find this deployment from any directory, a notice naming the file your provider key belongs in, an optional worker service, optional GitHub App credentials (`setup github`, the one step that mints a private key), an optional trigger edge (receiver service, docker compose profile, or the polling command), and an optional first **cron** trigger for the repo you are sitting in. Every step shows what it will do, asks first, and can be declined; nothing is written into your repo and no credential passes through a dialog. A deployment whose queue is merely down keeps the unreachable banner instead: setup appears when there is nothing, never over an outage.
|
|
62
80
|
|
|
63
81
|
Already have a deployment? The panel finds it through the deployment pointer setup writes, or through the same env vars your worker uses (`VALKEY_URL`, `PI_LOGS_DIR`, `PI_SETTINGS_FILE`, `PI_TRIGGERS_FILE`, `PI_PAUSE_WINDOWS_FILE`, `PI_SUBSCRIPTIONS_FILE`). Your env always wins.
|
|
64
82
|
|
|
83
|
+
`dispatch_run` is inert until you set one more variable yourself: `PI_DISPATCH_RUN_ROOTS` defaults to empty, and an empty allowlist refuses every folder. The deployment pointer deliberately cannot set it (the pointer carries paths, never capability grants), so widening that allowlist is always your own env edit.
|
|
84
|
+
|
|
65
85
|
## Get the whole thing
|
|
66
86
|
|
|
67
87
|
### → **https://github.com/edgehero/pi-dispatch**
|
package/dist/index.mjs
CHANGED
|
@@ -7952,6 +7952,11 @@ function validateResumeFlag(run, at, path) {
|
|
|
7952
7952
|
if (run.resume !== void 0 && typeof run.resume !== "boolean") {
|
|
7953
7953
|
throw configError(`${at}: run.resume must be true or false when present: ${path}`);
|
|
7954
7954
|
}
|
|
7955
|
+
if (run.resume === true && run.kind === "local") {
|
|
7956
|
+
throw configError(
|
|
7957
|
+
`${at}: run.resume is not yet covered for cron triggers (forge triggers only in this version) -- resolveSession is handed to the forge preparers only, so a local job would stage no transcript, mount no /session and promote nothing, then exit 0 as though it had; the local session key exists in session-key.mjs and nothing reaches it, so this is a gap to close, not a limit: ${path}`
|
|
7958
|
+
);
|
|
7959
|
+
}
|
|
7955
7960
|
return run.resume;
|
|
7956
7961
|
}
|
|
7957
7962
|
function validateImageRef(run, at, path) {
|
|
@@ -37456,17 +37461,24 @@ var init_dashboard = __esm({
|
|
|
37456
37461
|
// src/setup-wizard.ts
|
|
37457
37462
|
var setup_wizard_exports = {};
|
|
37458
37463
|
__export(setup_wizard_exports, {
|
|
37464
|
+
RECEIVER_VERSION: () => RECEIVER_VERSION,
|
|
37459
37465
|
RUNTIME_VERSION: () => RUNTIME_VERSION,
|
|
37460
37466
|
detectDeployment: () => detectDeployment,
|
|
37467
|
+
dockerHint: () => dockerHint,
|
|
37461
37468
|
listRepoSkills: () => listRepoSkills,
|
|
37462
37469
|
npmInstallArgs: () => npmInstallArgs,
|
|
37470
|
+
npmInstallArgsFor: () => npmInstallArgsFor,
|
|
37463
37471
|
npmSpawnOptions: () => npmSpawnOptions,
|
|
37472
|
+
probeDocker: () => probeDocker,
|
|
37473
|
+
readInstalledVersion: () => readInstalledVersion,
|
|
37474
|
+
receiverInstallArgs: () => receiverInstallArgs,
|
|
37464
37475
|
registerNudge: () => registerNudge,
|
|
37465
37476
|
runAttached: () => runAttached,
|
|
37466
|
-
runSetupWizard: () => runSetupWizard
|
|
37477
|
+
runSetupWizard: () => runSetupWizard,
|
|
37478
|
+
runtimeDirFor: () => runtimeDirFor
|
|
37467
37479
|
});
|
|
37468
37480
|
import * as nodeFs4 from "node:fs";
|
|
37469
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
37481
|
+
import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
|
|
37470
37482
|
import { homedir as homedir2 } from "node:os";
|
|
37471
37483
|
import { join as join6 } from "node:path";
|
|
37472
37484
|
function envIsSet(env, key) {
|
|
@@ -37545,10 +37557,10 @@ ${title}
|
|
|
37545
37557
|
const result = await custom.call(ctx.ui, factory, { overlay: true });
|
|
37546
37558
|
return result ?? { code: null, error: new Error("the overlay closed before the command finished") };
|
|
37547
37559
|
}
|
|
37548
|
-
function
|
|
37560
|
+
function npmInstallArgsFor(pkgName, version) {
|
|
37549
37561
|
return [
|
|
37550
37562
|
"install",
|
|
37551
|
-
|
|
37563
|
+
`${pkgName}@${version}`,
|
|
37552
37564
|
"--omit=dev",
|
|
37553
37565
|
"--omit=optional",
|
|
37554
37566
|
"--ignore-scripts",
|
|
@@ -37557,6 +37569,54 @@ function npmInstallArgs() {
|
|
|
37557
37569
|
"--loglevel=error"
|
|
37558
37570
|
];
|
|
37559
37571
|
}
|
|
37572
|
+
function npmInstallArgs() {
|
|
37573
|
+
return npmInstallArgsFor(RUNTIME_PKG, RUNTIME_VERSION);
|
|
37574
|
+
}
|
|
37575
|
+
function receiverInstallArgs() {
|
|
37576
|
+
return npmInstallArgsFor(RECEIVER_PKG, RECEIVER_VERSION);
|
|
37577
|
+
}
|
|
37578
|
+
function probeDocker(execFn = execFileSync3) {
|
|
37579
|
+
try {
|
|
37580
|
+
execFn("docker", ["version"], { stdio: "ignore", timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
37581
|
+
return { ok: true };
|
|
37582
|
+
} catch (err) {
|
|
37583
|
+
if (err?.code === "ENOENT" || err?.errno === "ENOENT") return { missing: true };
|
|
37584
|
+
if (typeof err?.status === "number") return { daemonDown: `\`docker version\` exited ${err.status}` };
|
|
37585
|
+
return { daemonDown: err?.message ?? String(err) };
|
|
37586
|
+
}
|
|
37587
|
+
}
|
|
37588
|
+
function dockerHint(platform) {
|
|
37589
|
+
if (platform === "darwin") {
|
|
37590
|
+
return "macOS: install and START Docker Desktop (docs.docker.com/desktop/install/mac-install) or OrbStack (orbstack.dev) \u2014 either one provides the `docker` CLI and a running daemon.";
|
|
37591
|
+
}
|
|
37592
|
+
if (platform === "win32") {
|
|
37593
|
+
return "Windows: install and START Docker Desktop (docs.docker.com/desktop/install/windows-install) \u2014 it provides the `docker` CLI and a running daemon.";
|
|
37594
|
+
}
|
|
37595
|
+
return "Linux: install the docker engine from your distribution's own packages, following docs.docker.com/engine/install, then start and enable its daemon.";
|
|
37596
|
+
}
|
|
37597
|
+
async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
|
|
37598
|
+
for (let round = 1; ; round++) {
|
|
37599
|
+
const probe = probeDockerFn();
|
|
37600
|
+
if (probe.ok) return true;
|
|
37601
|
+
const why = probe.missing ? "no `docker` on PATH" : `docker is installed but not answering (${probe.daemonDown})`;
|
|
37602
|
+
notify?.(`docker check: ${why}. ${dockerHint(platform)}`, "warning");
|
|
37603
|
+
if (round >= DOCKER_PROBE_ROUNDS) {
|
|
37604
|
+
notify?.(
|
|
37605
|
+
`docker still not ready after ${DOCKER_PROBE_ROUNDS} checks \u2014 stopping setup before anything was installed. Re-run /dispatch setup once \`docker version\` answers.`,
|
|
37606
|
+
"error"
|
|
37607
|
+
);
|
|
37608
|
+
return false;
|
|
37609
|
+
}
|
|
37610
|
+
const choice = await ui.select("Docker is not ready", ["Re-check", "Continue anyway", "Stop"]);
|
|
37611
|
+
if (choice === "Re-check") continue;
|
|
37612
|
+
if (choice === "Continue anyway") {
|
|
37613
|
+
notify?.("continuing without a docker answer \u2014 `up` runs its own docker checks and refuses there if it is still missing", "info");
|
|
37614
|
+
return true;
|
|
37615
|
+
}
|
|
37616
|
+
notify?.("setup stopped before anything was installed \u2014 re-run /dispatch setup when docker is ready", "info");
|
|
37617
|
+
return false;
|
|
37618
|
+
}
|
|
37619
|
+
}
|
|
37560
37620
|
function npmSpawnOptions(platform, dir) {
|
|
37561
37621
|
return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
|
|
37562
37622
|
}
|
|
@@ -37568,6 +37628,9 @@ function listRepoSkills(cwd, fs = nodeFs4) {
|
|
|
37568
37628
|
return [];
|
|
37569
37629
|
}
|
|
37570
37630
|
}
|
|
37631
|
+
function runtimeDirFor(dir, pkg = "pi-dispatch") {
|
|
37632
|
+
return join6(dir, "node_modules", "@edgehero", pkg);
|
|
37633
|
+
}
|
|
37571
37634
|
function readInstalledVersion(fs, runtimeDir) {
|
|
37572
37635
|
try {
|
|
37573
37636
|
const pkg = JSON.parse(fs.readFileSync(join6(runtimeDir, "package.json"), "utf8"));
|
|
@@ -37576,6 +37639,13 @@ function readInstalledVersion(fs, runtimeDir) {
|
|
|
37576
37639
|
return void 0;
|
|
37577
37640
|
}
|
|
37578
37641
|
}
|
|
37642
|
+
function ensureDeploymentPackageJson(fs, dir) {
|
|
37643
|
+
const rootPkg = join6(dir, "package.json");
|
|
37644
|
+
if (!fs.existsSync(rootPkg)) {
|
|
37645
|
+
fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
|
|
37646
|
+
`);
|
|
37647
|
+
}
|
|
37648
|
+
}
|
|
37579
37649
|
async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
37580
37650
|
const {
|
|
37581
37651
|
fs = nodeFs4,
|
|
@@ -37591,14 +37661,16 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37591
37661
|
writeTriggersFn = writeTriggers,
|
|
37592
37662
|
resolvePathsFn = resolvePaths,
|
|
37593
37663
|
listRepoSkillsFn = listRepoSkills,
|
|
37594
|
-
existsSyncFn = (p) => fs.existsSync(p)
|
|
37664
|
+
existsSyncFn = (p) => fs.existsSync(p),
|
|
37665
|
+
probeDockerFn = probeDocker,
|
|
37666
|
+
initialDetection
|
|
37595
37667
|
} = deps;
|
|
37596
37668
|
const ui = ctx?.ui;
|
|
37597
37669
|
if (typeof ui?.input !== "function" || typeof ui?.select !== "function" || typeof ui?.confirm !== "function") {
|
|
37598
37670
|
notify?.("setup needs dialogs (newer pi) \u2014 no input/select/confirm available", "warning");
|
|
37599
37671
|
return;
|
|
37600
37672
|
}
|
|
37601
|
-
const det = await detectFn({ env, cwd: ctx?.cwd, fs });
|
|
37673
|
+
const det = initialDetection ?? await detectFn({ env, cwd: ctx?.cwd, fs });
|
|
37602
37674
|
notify?.(`pi-dispatch setup \u2014 detected: ${det.detail}`, "info");
|
|
37603
37675
|
const intent = await ui.select("pi-dispatch setup", ["Guided setup", "Open the panel anyway", "Cancel"]);
|
|
37604
37676
|
if (intent === "Open the panel anyway") {
|
|
@@ -37615,8 +37687,9 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37615
37687
|
notify?.(`could not create ${dir}: ${err?.message ?? err} \u2014 setup cannot continue`, "error");
|
|
37616
37688
|
return;
|
|
37617
37689
|
}
|
|
37618
|
-
const runtimeDir =
|
|
37690
|
+
const runtimeDir = runtimeDirFor(dir);
|
|
37619
37691
|
const cliPath = join6(runtimeDir, "src", "cli.mjs");
|
|
37692
|
+
if (!await ensureDocker(ui, notify, { platform, probeDockerFn })) return;
|
|
37620
37693
|
if (readInstalledVersion(fs, runtimeDir) === RUNTIME_VERSION) {
|
|
37621
37694
|
notify?.(`runtime ${RUNTIME_VERSION} already installed in ${dir} \u2014 skipping npm install`, "info");
|
|
37622
37695
|
} else {
|
|
@@ -37632,11 +37705,7 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37632
37705
|
if (!okInstall) {
|
|
37633
37706
|
notify?.(`install skipped \u2014 later steps assume the runtime at ${runtimeDir}`, "info");
|
|
37634
37707
|
} else {
|
|
37635
|
-
|
|
37636
|
-
if (!fs.existsSync(rootPkg)) {
|
|
37637
|
-
fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
|
|
37638
|
-
`);
|
|
37639
|
-
}
|
|
37708
|
+
ensureDeploymentPackageJson(fs, dir);
|
|
37640
37709
|
const res = await runAttachedFn(ctx, {
|
|
37641
37710
|
title: `npm install @edgehero/pi-dispatch@${RUNTIME_VERSION} \u2014 in ${dir}`,
|
|
37642
37711
|
argv0: bin,
|
|
@@ -37738,6 +37807,7 @@ ${JSON.stringify(pointer, null, 2)}`);
|
|
|
37738
37807
|
} else {
|
|
37739
37808
|
notify?.(`later: node ${cliPath} setup github (in ${dir})`, "info");
|
|
37740
37809
|
}
|
|
37810
|
+
await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn });
|
|
37741
37811
|
if (typeof ctx?.cwd === "string" && ctx.cwd !== "" && existsSyncFn(ctx.cwd)) {
|
|
37742
37812
|
await offerFirstTrigger(ctx.cwd, dir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn });
|
|
37743
37813
|
}
|
|
@@ -37747,6 +37817,110 @@ ${JSON.stringify(pointer, null, 2)}`);
|
|
|
37747
37817
|
await openDashboardFn(resolvePathsFn(env), ctx, notify);
|
|
37748
37818
|
}
|
|
37749
37819
|
}
|
|
37820
|
+
async function offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn }) {
|
|
37821
|
+
const choice = await ui.select("How should GitHub events reach the queue?", [
|
|
37822
|
+
EDGE_SERVICE,
|
|
37823
|
+
EDGE_COMPOSE,
|
|
37824
|
+
EDGE_POLL,
|
|
37825
|
+
"Skip"
|
|
37826
|
+
]);
|
|
37827
|
+
if (choice === EDGE_SERVICE) {
|
|
37828
|
+
const args = receiverInstallArgs();
|
|
37829
|
+
const { bin, options } = npmSpawnOptions(platform, dir);
|
|
37830
|
+
const unitHint = `node ${cliPath} service install --receiver (in ${dir})`;
|
|
37831
|
+
const ok = await ui.confirm(
|
|
37832
|
+
"Install the webhook receiver",
|
|
37833
|
+
`Run in ${dir}:
|
|
37834
|
+
${bin} ${args.join(" ")}
|
|
37835
|
+
|
|
37836
|
+
then, in the same folder:
|
|
37837
|
+
${execPath} ${cliPath} service install --receiver
|
|
37838
|
+
|
|
37839
|
+
(scripts disabled; installs the pinned ${RECEIVER_PKG}@${RECEIVER_VERSION}, then renders a user-level receiver unit for this host)`
|
|
37840
|
+
);
|
|
37841
|
+
if (!ok) {
|
|
37842
|
+
notify?.(`receiver skipped \u2014 later: ${bin} ${args.join(" ")} (in ${dir}), then ${unitHint}`, "info");
|
|
37843
|
+
return;
|
|
37844
|
+
}
|
|
37845
|
+
ensureDeploymentPackageJson(fs, dir);
|
|
37846
|
+
const res = await runAttachedFn(ctx, {
|
|
37847
|
+
title: `npm install ${RECEIVER_PKG}@${RECEIVER_VERSION} \u2014 in ${dir}`,
|
|
37848
|
+
argv0: bin,
|
|
37849
|
+
args,
|
|
37850
|
+
cwd: options.cwd,
|
|
37851
|
+
shell: options.shell,
|
|
37852
|
+
env
|
|
37853
|
+
});
|
|
37854
|
+
const installed = readInstalledVersion(fs, runtimeDirFor(dir, "pi-dispatch-receiver"));
|
|
37855
|
+
if (installed !== RECEIVER_VERSION) {
|
|
37856
|
+
const how = res?.error ? `npm could not run: ${res.error.message}` : `npm exited ${res?.code}`;
|
|
37857
|
+
notify?.(
|
|
37858
|
+
`${how}; installed receiver version is ${installed ?? "(absent)"}, not the pinned ${RECEIVER_VERSION} \u2014 skipping the receiver unit (the rest of the deployment is unaffected). Fix the install, then run: ${unitHint}`,
|
|
37859
|
+
"error"
|
|
37860
|
+
);
|
|
37861
|
+
return;
|
|
37862
|
+
}
|
|
37863
|
+
await runAttachedFn(ctx, {
|
|
37864
|
+
title: `pi-dispatch service install --receiver \u2014 in ${dir}`,
|
|
37865
|
+
argv0: execPath,
|
|
37866
|
+
args: [cliPath, "service", "install", "--receiver"],
|
|
37867
|
+
cwd: dir,
|
|
37868
|
+
env
|
|
37869
|
+
});
|
|
37870
|
+
return;
|
|
37871
|
+
}
|
|
37872
|
+
if (choice === EDGE_COMPOSE) {
|
|
37873
|
+
const src = join6(runtimeDirFor(dir), "deploy", "docker-compose.yml");
|
|
37874
|
+
const dest = join6(dir, "docker-compose.yml");
|
|
37875
|
+
if (fs.existsSync(dest)) {
|
|
37876
|
+
notify?.(`${dest} already exists \u2014 keeping yours as it is (setup never overwrites a compose file you may have edited)`, "info");
|
|
37877
|
+
} else {
|
|
37878
|
+
try {
|
|
37879
|
+
fs.copyFileSync(src, dest);
|
|
37880
|
+
notify?.(`copied the runtime's compose file to ${dest}`, "info");
|
|
37881
|
+
} catch (err) {
|
|
37882
|
+
notify?.(
|
|
37883
|
+
`could not copy ${src}: ${err?.message ?? err} \u2014 the receiver profile needs that file; skipping this step (install the runtime first, or copy it by hand)`,
|
|
37884
|
+
"error"
|
|
37885
|
+
);
|
|
37886
|
+
return;
|
|
37887
|
+
}
|
|
37888
|
+
}
|
|
37889
|
+
const args = ["compose", "-f", dest, "--profile", "receiver", "up", "-d"];
|
|
37890
|
+
const ok = await ui.confirm(
|
|
37891
|
+
"Start the receiver container",
|
|
37892
|
+
`Run in ${dir}:
|
|
37893
|
+
docker ${args.join(" ")}
|
|
37894
|
+
|
|
37895
|
+
the receiver profile reads ${join6(dir, ".env")} \u2014 WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`
|
|
37896
|
+
);
|
|
37897
|
+
if (!ok) {
|
|
37898
|
+
notify?.(`later: docker ${args.join(" ")} (in ${dir})`, "info");
|
|
37899
|
+
return;
|
|
37900
|
+
}
|
|
37901
|
+
await runAttachedFn(ctx, {
|
|
37902
|
+
title: `docker compose --profile receiver up -d \u2014 in ${dir}`,
|
|
37903
|
+
argv0: "docker",
|
|
37904
|
+
args,
|
|
37905
|
+
cwd: dir,
|
|
37906
|
+
env
|
|
37907
|
+
});
|
|
37908
|
+
return;
|
|
37909
|
+
}
|
|
37910
|
+
if (choice === EDGE_POLL) {
|
|
37911
|
+
notify?.(
|
|
37912
|
+
`polling needs no public URL, no DNS and no tunnel \u2014 two commands, in ${dir}:
|
|
37913
|
+
1) node ${cliPath} setup github --no-webhook (mints the App with its webhook INACTIVE \u2014 the polling-ready shape)
|
|
37914
|
+
2) npx @edgehero/pi-dispatch-receiver poll (the producer; run it from the deployment folder, under whatever keeps it alive)`,
|
|
37915
|
+
"info"
|
|
37916
|
+
);
|
|
37917
|
+
return;
|
|
37918
|
+
}
|
|
37919
|
+
notify?.(
|
|
37920
|
+
`trigger edge left for later \u2014 all three ways stay open from ${dir}: \`service install --receiver\` (a receiver unit on this host), \`docker compose --profile receiver up -d\` (a receiver container), or \`npx @edgehero/pi-dispatch-receiver poll\` (no public URL at all). Local cron triggers need none of them.`,
|
|
37921
|
+
"info"
|
|
37922
|
+
);
|
|
37923
|
+
}
|
|
37750
37924
|
async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn }) {
|
|
37751
37925
|
const offer = await ui.select("First trigger", ["A cron trigger for this repo", "Skip"]);
|
|
37752
37926
|
if (offer !== "A cron trigger for this repo") return;
|
|
@@ -37806,23 +37980,40 @@ function registerNudge(pi, deps = {}) {
|
|
|
37806
37980
|
}
|
|
37807
37981
|
});
|
|
37808
37982
|
}
|
|
37809
|
-
var RUNTIME_VERSION, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, CRON_ID_RE;
|
|
37983
|
+
var RUNTIME_VERSION, RECEIVER_VERSION, RUNTIME_PKG, RECEIVER_PKG, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, DOCKER_PROBE_ROUNDS, DOCKER_PROBE_TIMEOUT_MS, CRON_ID_RE, EDGE_SERVICE, EDGE_COMPOSE, EDGE_POLL;
|
|
37810
37984
|
var init_setup_wizard = __esm({
|
|
37811
37985
|
"src/setup-wizard.ts"() {
|
|
37812
37986
|
init_flow_gate();
|
|
37813
37987
|
init_deployment_pointer();
|
|
37814
37988
|
init_read_model();
|
|
37815
37989
|
init_index();
|
|
37816
|
-
RUNTIME_VERSION = "0.1.
|
|
37990
|
+
RUNTIME_VERSION = "0.1.2";
|
|
37991
|
+
RECEIVER_VERSION = "0.1.1";
|
|
37992
|
+
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
37993
|
+
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
37817
37994
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
37818
37995
|
CWD_SCAFFOLD_FILES = [".env", "triggers.json", "pause-windows.json", "subscriptions.json"];
|
|
37819
37996
|
ENV_PATH_KEYS = POINTER_ENV_ALLOWLIST.filter((key) => key !== "VALKEY_URL");
|
|
37997
|
+
DOCKER_PROBE_ROUNDS = 5;
|
|
37998
|
+
DOCKER_PROBE_TIMEOUT_MS = 1e4;
|
|
37820
37999
|
CRON_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
38000
|
+
EDGE_SERVICE = "Install the webhook receiver as a service";
|
|
38001
|
+
EDGE_COMPOSE = "Run the receiver with docker compose";
|
|
38002
|
+
EDGE_POLL = "Show the polling command";
|
|
37821
38003
|
}
|
|
37822
38004
|
});
|
|
37823
38005
|
|
|
37824
38006
|
// src/index.ts
|
|
38007
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
37825
38008
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
38009
|
+
import * as nodeFs5 from "node:fs";
|
|
38010
|
+
function computePiVersionAdvisory(version, supported) {
|
|
38011
|
+
if (version === supported || version === "0.0.0") return void 0;
|
|
38012
|
+
return `pi-dispatch admin: running on pi ${version}, tested with pi ${supported} \u2014 things should work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`;
|
|
38013
|
+
}
|
|
38014
|
+
function _setPiVersionAdvisoryForTests(message) {
|
|
38015
|
+
piVersionAdvisory = message;
|
|
38016
|
+
}
|
|
37826
38017
|
function admin(pi) {
|
|
37827
38018
|
for (const member of USED_API) {
|
|
37828
38019
|
if (typeof pi[member] !== "function") {
|
|
@@ -37832,6 +38023,10 @@ function admin(pi) {
|
|
|
37832
38023
|
return;
|
|
37833
38024
|
}
|
|
37834
38025
|
}
|
|
38026
|
+
if (!piVersionCheckDone) {
|
|
38027
|
+
piVersionCheckDone = true;
|
|
38028
|
+
piVersionAdvisory = computePiVersionAdvisory(VERSION, SUPPORTED_PI_VERSION);
|
|
38029
|
+
}
|
|
37835
38030
|
try {
|
|
37836
38031
|
applyDeploymentPointer();
|
|
37837
38032
|
} catch {
|
|
@@ -38262,22 +38457,28 @@ async function dispatch(pi, args, ctx) {
|
|
|
38262
38457
|
setGlyphs(paths.asciiGlyphs);
|
|
38263
38458
|
const pnote = takePointerNotice();
|
|
38264
38459
|
if (pnote) notify?.(pnote, "warning");
|
|
38460
|
+
if (piVersionAdvisory) {
|
|
38461
|
+
notify?.(piVersionAdvisory, "info");
|
|
38462
|
+
piVersionAdvisory = void 0;
|
|
38463
|
+
}
|
|
38265
38464
|
if (sub === "") {
|
|
38266
|
-
const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
|
|
38465
|
+
const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2, readInstalledVersion: readInstalledVersion2, runtimeDirFor: runtimeDirFor2, RUNTIME_VERSION: RUNTIME_VERSION2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
|
|
38267
38466
|
const det = await detectDeployment2({ env: process.env, cwd: ctx?.cwd });
|
|
38268
38467
|
if (det.state === "none") {
|
|
38269
|
-
|
|
38270
|
-
|
|
38271
|
-
|
|
38272
|
-
|
|
38468
|
+
await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard, initialDetection: det });
|
|
38469
|
+
return;
|
|
38470
|
+
}
|
|
38471
|
+
if (det.state === "pointer" && !runtimeSkewNoticed) {
|
|
38472
|
+
const ptrRes = readPointer({ path: pointerPath(process.env) });
|
|
38473
|
+
const deploymentDir = ptrRes.pointer?.deploymentDir;
|
|
38474
|
+
const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
|
|
38475
|
+
if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
|
|
38476
|
+
runtimeSkewNoticed = true;
|
|
38477
|
+
notify?.(
|
|
38478
|
+
`deployment runtime ${installed}, this console pins ${RUNTIME_VERSION2} \u2014 run /dispatch setup to upgrade`,
|
|
38479
|
+
"warning"
|
|
38273
38480
|
);
|
|
38274
|
-
if (ok) {
|
|
38275
|
-
await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
|
|
38276
|
-
return;
|
|
38277
|
-
}
|
|
38278
38481
|
}
|
|
38279
|
-
notify?.(`${USAGE} \u2014 run /dispatch setup when you are ready`, "info");
|
|
38280
|
-
return;
|
|
38281
38482
|
}
|
|
38282
38483
|
if (det.state === "cwd" || det.state === "reachable") {
|
|
38283
38484
|
notify?.(`using ${det.detail} \u2014 /dispatch setup can write a deployment pointer so this works from anywhere`, "info");
|
|
@@ -38846,7 +39047,7 @@ function completeArguments(prefix) {
|
|
|
38846
39047
|
}
|
|
38847
39048
|
return null;
|
|
38848
39049
|
}
|
|
38849
|
-
var USED_API, SUPPORTED_PI_VERSION, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, VIEWPORT_LINES;
|
|
39050
|
+
var USED_API, SUPPORTED_PI_VERSION, piVersionAdvisory, piVersionCheckDone, runtimeSkewNoticed, CHANNEL, REBUILT_NOTICE, USAGE, KNOWN_SUBCOMMANDS, FORGE_PROMPT, PR_ACTION_VOCAB, COSTS_WINDOWS2, COSTS_USAGE, DAY_MS2, PRICING, VIEWPORT_LINES;
|
|
38850
39051
|
var init_index = __esm({
|
|
38851
39052
|
"src/index.ts"() {
|
|
38852
39053
|
init_build();
|
|
@@ -38863,6 +39064,8 @@ var init_index = __esm({
|
|
|
38863
39064
|
init_keys2();
|
|
38864
39065
|
USED_API = ["registerCommand", "registerTool", "sendMessage", "on"];
|
|
38865
39066
|
SUPPORTED_PI_VERSION = "0.80.7";
|
|
39067
|
+
piVersionCheckDone = false;
|
|
39068
|
+
runtimeSkewNoticed = false;
|
|
38866
39069
|
CHANNEL = "pi-dispatch-admin";
|
|
38867
39070
|
REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
|
|
38868
39071
|
USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
|
|
@@ -38899,7 +39102,9 @@ init_index();
|
|
|
38899
39102
|
export {
|
|
38900
39103
|
SUPPORTED_PI_VERSION,
|
|
38901
39104
|
USED_API,
|
|
39105
|
+
_setPiVersionAdvisoryForTests,
|
|
38902
39106
|
buildTriggerEntry,
|
|
39107
|
+
computePiVersionAdvisory,
|
|
38903
39108
|
admin as default,
|
|
38904
39109
|
handleDashboardAction
|
|
38905
39110
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"ioredis": "5.11.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@earendil-works/pi-coding-agent": "
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@earendil-works/pi-coding-agent": "0.80.7",
|