@edgehero/pi-dispatch-admin 0.4.0 → 0.6.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 +363 -47
- package/package.json +2 -2
- package/skills/operate-pi-dispatch/SKILL.md +6 -1
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. Anything you already installed with `pi install` is staged automatically, at the version your host has, so there is nothing to declare twice.
|
|
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
|
@@ -7935,11 +7935,13 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7935
7935
|
}
|
|
7936
7936
|
const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
|
|
7937
7937
|
const image = validateImageRef(run, `cron trigger "${id}"`, path);
|
|
7938
|
+
const skillsDir = validateSkillsDir(run, `cron trigger "${id}"`, path);
|
|
7939
|
+
validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
|
|
7938
7940
|
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
7939
7941
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
7940
7942
|
return {
|
|
7941
7943
|
on: { type: "cron", id, pattern },
|
|
7942
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume }
|
|
7944
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...skillsDir !== void 0 && { skillsDir } }
|
|
7943
7945
|
};
|
|
7944
7946
|
}
|
|
7945
7947
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -7952,6 +7954,11 @@ function validateResumeFlag(run, at, path) {
|
|
|
7952
7954
|
if (run.resume !== void 0 && typeof run.resume !== "boolean") {
|
|
7953
7955
|
throw configError(`${at}: run.resume must be true or false when present: ${path}`);
|
|
7954
7956
|
}
|
|
7957
|
+
if (run.resume === true && run.kind === "local") {
|
|
7958
|
+
throw configError(
|
|
7959
|
+
`${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}`
|
|
7960
|
+
);
|
|
7961
|
+
}
|
|
7955
7962
|
return run.resume;
|
|
7956
7963
|
}
|
|
7957
7964
|
function validateImageRef(run, at, path) {
|
|
@@ -7968,6 +7975,31 @@ function validateImageRef(run, at, path) {
|
|
|
7968
7975
|
}
|
|
7969
7976
|
return image;
|
|
7970
7977
|
}
|
|
7978
|
+
function validateSkillsDir(run, at, path) {
|
|
7979
|
+
const dir = run.skillsDir;
|
|
7980
|
+
if (dir === void 0) return void 0;
|
|
7981
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
7982
|
+
throw configError(`${at}: run.skillsDir must be a non-empty string when present: ${path}`);
|
|
7983
|
+
}
|
|
7984
|
+
if (dir !== dir.trim()) {
|
|
7985
|
+
throw configError(`${at}: run.skillsDir must not have leading or trailing whitespace (got ${JSON.stringify(dir)}): ${path}`);
|
|
7986
|
+
}
|
|
7987
|
+
return dir;
|
|
7988
|
+
}
|
|
7989
|
+
function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
7990
|
+
const text = run.instructions;
|
|
7991
|
+
if (text === void 0) return void 0;
|
|
7992
|
+
if (cron) {
|
|
7993
|
+
throw configError(`${at}: run.instructions is not accepted on a cron trigger -- a local job's prompt IS run.task, the same operator-authored text in the same place. Put the standing instruction at the top of run.task: ${path}`);
|
|
7994
|
+
}
|
|
7995
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
7996
|
+
throw configError(`${at}: run.instructions must be a non-empty string when present: ${path}`);
|
|
7997
|
+
}
|
|
7998
|
+
if (text.length > INSTRUCTIONS_MAX) {
|
|
7999
|
+
throw configError(`${at}: run.instructions is ${text.length} characters, over the ${INSTRUCTIONS_MAX} cap -- a standing instruction is a sentence or two. Anything longer belongs in the flow's own SKILL.md, or in the global overlay's APPEND_SYSTEM.md if it applies to every job: ${path}`);
|
|
8000
|
+
}
|
|
8001
|
+
return text;
|
|
8002
|
+
}
|
|
7971
8003
|
function validatePredicate(on, index, path, requirePositive) {
|
|
7972
8004
|
const at = `trigger at index ${index}`;
|
|
7973
8005
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -8022,12 +8054,14 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8022
8054
|
}
|
|
8023
8055
|
const packages = validatePackagesFlag(run, at, path);
|
|
8024
8056
|
const image = validateImageRef(run, at, path);
|
|
8057
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
8058
|
+
const instructions = validateInstructions(run, at, path);
|
|
8025
8059
|
const resume = validateResumeFlag(run, at, path);
|
|
8026
8060
|
const repository = validateRepository(run, "label", at, path);
|
|
8027
8061
|
const replicas = validateReplicas(run, at, path);
|
|
8028
8062
|
return {
|
|
8029
8063
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
8030
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
|
|
8064
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8031
8065
|
};
|
|
8032
8066
|
}
|
|
8033
8067
|
function normalizeComment(on, run, index, path, state) {
|
|
@@ -8044,12 +8078,14 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8044
8078
|
}
|
|
8045
8079
|
const packages = validatePackagesFlag(run, at, path);
|
|
8046
8080
|
const image = validateImageRef(run, at, path);
|
|
8081
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
8082
|
+
const instructions = validateInstructions(run, at, path);
|
|
8047
8083
|
const resume = validateResumeFlag(run, at, path);
|
|
8048
8084
|
const repository = validateRepository(run, "comment", at, path);
|
|
8049
8085
|
const replicas = validateReplicas(run, at, path);
|
|
8050
8086
|
return {
|
|
8051
8087
|
on: { type: "comment", phrase: on.phrase },
|
|
8052
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...repository !== void 0 && { repository } }
|
|
8088
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8053
8089
|
};
|
|
8054
8090
|
}
|
|
8055
8091
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -8065,6 +8101,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8065
8101
|
throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
8066
8102
|
}
|
|
8067
8103
|
}
|
|
8104
|
+
const reviewState = validateReviewState(on, actions, run, at, path);
|
|
8068
8105
|
const labelAction = forgeSpec(run.kind)?.prLabelAction;
|
|
8069
8106
|
const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
|
|
8070
8107
|
if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
|
|
@@ -8076,22 +8113,55 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8076
8113
|
}
|
|
8077
8114
|
const packages = validatePackagesFlag(run, at, path);
|
|
8078
8115
|
const image = validateImageRef(run, at, path);
|
|
8116
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
8117
|
+
const instructions = validateInstructions(run, at, path);
|
|
8079
8118
|
const resume = validateResumeFlag(run, at, path);
|
|
8080
8119
|
validateRepository(run, "pull_request", at, path);
|
|
8081
8120
|
const replicas = validateReplicas(run, at, path);
|
|
8082
8121
|
return {
|
|
8083
|
-
on: {
|
|
8084
|
-
|
|
8122
|
+
on: {
|
|
8123
|
+
type: "pull_request",
|
|
8124
|
+
action: [...actions],
|
|
8125
|
+
// Absent rather than present-and-undefined: an unnarrowed rule's normalized shape must stay
|
|
8126
|
+
// byte-identical to the one every pre-#66 trigger file produces.
|
|
8127
|
+
...reviewState !== void 0 && { reviewState },
|
|
8128
|
+
any: predicate.any,
|
|
8129
|
+
all: predicate.all,
|
|
8130
|
+
none: predicate.none
|
|
8131
|
+
},
|
|
8132
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
|
|
8085
8133
|
};
|
|
8086
8134
|
}
|
|
8087
|
-
|
|
8135
|
+
function validateReviewState(on, actions, run, at, path) {
|
|
8136
|
+
if (on.reviewState === void 0) return void 0;
|
|
8137
|
+
if (run.kind !== "github") {
|
|
8138
|
+
throw configError(`${at}: on.reviewState is github-only (got ${run.kind}), no other forge reports a review verdict: ${path}`);
|
|
8139
|
+
}
|
|
8140
|
+
if (!actions.includes(REVIEW_ACTION)) {
|
|
8141
|
+
throw configError(`${at}: on.reviewState requires on.action to include ${JSON.stringify(REVIEW_ACTION)}, otherwise it narrows nothing: ${path}`);
|
|
8142
|
+
}
|
|
8143
|
+
if (!Array.isArray(on.reviewState) || on.reviewState.length === 0) {
|
|
8144
|
+
throw configError(`${at}: on.reviewState must be a non-empty array: ${path}`);
|
|
8145
|
+
}
|
|
8146
|
+
const expected = [...REVIEW_STATES].join("|");
|
|
8147
|
+
for (const s of on.reviewState) {
|
|
8148
|
+
if (!REVIEW_STATES.has(s)) {
|
|
8149
|
+
throw configError(`${at}: on.reviewState has an unsupported review state ${JSON.stringify(s)} (expected ${expected}): ${path}`);
|
|
8150
|
+
}
|
|
8151
|
+
}
|
|
8152
|
+
return [...on.reviewState];
|
|
8153
|
+
}
|
|
8154
|
+
var ON_TYPES, PR_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, INSTRUCTIONS_MAX;
|
|
8088
8155
|
var init_triggers = __esm({
|
|
8089
8156
|
"../worker/src/triggers.mjs"() {
|
|
8090
8157
|
init_config2();
|
|
8091
8158
|
init_forges();
|
|
8092
8159
|
ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
|
|
8093
8160
|
PR_ACTIONS = {
|
|
8094
|
-
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
|
|
8161
|
+
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
|
|
8162
|
+
// GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
|
|
8163
|
+
// `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
|
|
8164
|
+
// what `on.reviewState` below exists to narrow.
|
|
8095
8165
|
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
|
|
8096
8166
|
// Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
|
|
8097
8167
|
// one-letter difference that an operator would otherwise discover as a trigger that loads clean and
|
|
@@ -8104,8 +8174,11 @@ var init_triggers = __esm({
|
|
|
8104
8174
|
// requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
|
|
8105
8175
|
azure: /* @__PURE__ */ new Set(["created", "updated"])
|
|
8106
8176
|
};
|
|
8177
|
+
REVIEW_STATES = /* @__PURE__ */ new Set(["approved", "changes_requested", "commented"]);
|
|
8178
|
+
REVIEW_ACTION = "review_submitted";
|
|
8107
8179
|
ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
8108
8180
|
REPLICAS_MAX = 3;
|
|
8181
|
+
INSTRUCTIONS_MAX = 2e3;
|
|
8109
8182
|
}
|
|
8110
8183
|
});
|
|
8111
8184
|
|
|
@@ -8437,7 +8510,7 @@ import { Queue } from "bullmq";
|
|
|
8437
8510
|
function makeQueue(connection) {
|
|
8438
8511
|
return new Queue(QUEUE, { connection });
|
|
8439
8512
|
}
|
|
8440
|
-
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8513
|
+
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8441
8514
|
const minute = now.toISOString().slice(0, 16);
|
|
8442
8515
|
const id = jobId ?? localJobId({ folder, flow, task, minute });
|
|
8443
8516
|
const data = {
|
|
@@ -8449,6 +8522,11 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
|
|
|
8449
8522
|
model,
|
|
8450
8523
|
maxTurns,
|
|
8451
8524
|
...image !== void 0 && { image },
|
|
8525
|
+
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
8526
|
+
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
8527
|
+
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
8528
|
+
// delivery -- and `trigger` is the object copied into /job/event.json.
|
|
8529
|
+
...skillsDir !== void 0 && { skillsDir },
|
|
8452
8530
|
...chainDepth !== void 0 && { chainDepth },
|
|
8453
8531
|
...parentJobId !== void 0 && { parentJobId }
|
|
8454
8532
|
};
|
|
@@ -8529,14 +8607,40 @@ var init_git_dirty = __esm({
|
|
|
8529
8607
|
}
|
|
8530
8608
|
});
|
|
8531
8609
|
|
|
8610
|
+
// ../worker/src/host-pi.mjs
|
|
8611
|
+
var init_host_pi = __esm({
|
|
8612
|
+
"../worker/src/host-pi.mjs"() {
|
|
8613
|
+
init_packages();
|
|
8614
|
+
}
|
|
8615
|
+
});
|
|
8616
|
+
|
|
8617
|
+
// ../worker/src/copy-tree.mjs
|
|
8618
|
+
var ENTRY_NAME_RE, INJECT_LIMITS;
|
|
8619
|
+
var init_copy_tree = __esm({
|
|
8620
|
+
"../worker/src/copy-tree.mjs"() {
|
|
8621
|
+
ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
|
|
8622
|
+
INJECT_LIMITS = Object.freeze({
|
|
8623
|
+
// The binding one, and its reason is NOT disk. Every loaded skill contributes a <name> and a
|
|
8624
|
+
// description (spec-capped at 1024 chars) to the SYSTEM PROMPT of every job of that trigger, so
|
|
8625
|
+
// this bounds the cached prefix rather than the filesystem. UNVERIFIED figure, in the sense
|
|
8626
|
+
// ISOLATION_FLAGS' --pids-limit=512 is: a ceiling on absurdity, not a measured budget.
|
|
8627
|
+
maxDirs: 64,
|
|
8628
|
+
maxFiles: 512,
|
|
8629
|
+
maxBytes: 4 << 20,
|
|
8630
|
+
maxDepth: 8
|
|
8631
|
+
});
|
|
8632
|
+
}
|
|
8633
|
+
});
|
|
8634
|
+
|
|
8532
8635
|
// ../worker/src/import-pi.mjs
|
|
8533
8636
|
import { execFile as execFile2 } from "node:child_process";
|
|
8534
8637
|
import { promisify as promisify2 } from "node:util";
|
|
8535
|
-
var
|
|
8638
|
+
var execFileAsync;
|
|
8536
8639
|
var init_import_pi = __esm({
|
|
8537
8640
|
"../worker/src/import-pi.mjs"() {
|
|
8538
8641
|
init_packages();
|
|
8539
|
-
|
|
8642
|
+
init_host_pi();
|
|
8643
|
+
init_copy_tree();
|
|
8540
8644
|
execFileAsync = promisify2(execFile2);
|
|
8541
8645
|
}
|
|
8542
8646
|
});
|
|
@@ -8622,14 +8726,14 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
8622
8726
|
const { name, version, dir } = entry;
|
|
8623
8727
|
if (!isNonEmptyString5(name) || !isNonEmptyString5(version) || !isNonEmptyString5(dir)) return null;
|
|
8624
8728
|
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
8625
|
-
packages.push({ name, version, dir });
|
|
8729
|
+
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
8626
8730
|
}
|
|
8627
8731
|
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
8628
8732
|
} catch {
|
|
8629
8733
|
return null;
|
|
8630
8734
|
}
|
|
8631
8735
|
}
|
|
8632
|
-
var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, MAX_DIR_LENGTH;
|
|
8736
|
+
var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
|
|
8633
8737
|
var init_packages = __esm({
|
|
8634
8738
|
"../worker/src/packages.mjs"() {
|
|
8635
8739
|
init_config2();
|
|
@@ -8638,6 +8742,8 @@ var init_packages = __esm({
|
|
|
8638
8742
|
PACKAGES_SUBDIR = "packages";
|
|
8639
8743
|
STAGE_MANIFEST = "packages.json";
|
|
8640
8744
|
CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
8745
|
+
FROM_DECLARED = "pi-packages";
|
|
8746
|
+
FROM_HOST = "host";
|
|
8641
8747
|
MAX_DIR_LENGTH = 64;
|
|
8642
8748
|
}
|
|
8643
8749
|
});
|
|
@@ -9051,6 +9157,8 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
9051
9157
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
9052
9158
|
const packages = run.packages !== false;
|
|
9053
9159
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
9160
|
+
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
|
|
9161
|
+
const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
|
|
9054
9162
|
const resume = run.resume === true;
|
|
9055
9163
|
const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
|
|
9056
9164
|
const replicas = Number.isInteger(run.replicas) && run.replicas > 1 ? run.replicas : null;
|
|
@@ -9067,12 +9175,14 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
9067
9175
|
model: typeof run.model === "string" ? run.model : null,
|
|
9068
9176
|
packages,
|
|
9069
9177
|
image,
|
|
9178
|
+
skillsDir,
|
|
9179
|
+
instructions,
|
|
9070
9180
|
resume
|
|
9071
9181
|
};
|
|
9072
9182
|
case "label":
|
|
9073
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, replicas, forge };
|
|
9183
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
9074
9184
|
case "comment":
|
|
9075
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, replicas, forge };
|
|
9185
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
9076
9186
|
case "pull_request":
|
|
9077
9187
|
return {
|
|
9078
9188
|
type: "pull_request",
|
|
@@ -9083,6 +9193,8 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
9083
9193
|
flow,
|
|
9084
9194
|
packages,
|
|
9085
9195
|
image,
|
|
9196
|
+
skillsDir,
|
|
9197
|
+
instructions,
|
|
9086
9198
|
resume,
|
|
9087
9199
|
replicas,
|
|
9088
9200
|
forge
|
|
@@ -35565,19 +35677,21 @@ function triggerLine(t) {
|
|
|
35565
35677
|
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
35566
35678
|
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
35567
35679
|
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
35680
|
+
const skl = t?.skillsDir ? ` [skills ${String(t.skillsDir).split(/[\\/]/).filter(Boolean).pop()}]` : "";
|
|
35681
|
+
const ins = t?.instructions === true ? " [instructions]" : "";
|
|
35568
35682
|
const res = t?.resume === true ? " [resume]" : "";
|
|
35569
35683
|
const rep = t?.replicas > 1 ? ` [x${t.replicas}]` : "";
|
|
35570
35684
|
switch (t?.type) {
|
|
35571
35685
|
case "cron":
|
|
35572
|
-
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}${rep}`;
|
|
35686
|
+
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
|
|
35573
35687
|
case "label":
|
|
35574
|
-
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
|
|
35688
|
+
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
|
|
35575
35689
|
case "comment":
|
|
35576
|
-
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
|
|
35690
|
+
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
|
|
35577
35691
|
case "pull_request": {
|
|
35578
35692
|
const clauses = ruleClauses(t);
|
|
35579
35693
|
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
35580
|
-
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}${rep}`;
|
|
35694
|
+
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${skl}${ins}${res}${rep}`;
|
|
35581
35695
|
}
|
|
35582
35696
|
default:
|
|
35583
35697
|
return "(unknown trigger)";
|
|
@@ -36963,7 +37077,9 @@ function trustModel(t) {
|
|
|
36963
37077
|
];
|
|
36964
37078
|
default:
|
|
36965
37079
|
return [
|
|
36966
|
-
|
|
37080
|
+
// A review trigger reads the REVIEWER's author_association, not the PR author's (issue #66), so it
|
|
37081
|
+
// cannot share the generic line: an operator reading "author gate" would picture the wrong person.
|
|
37082
|
+
t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : t?.type === "pull_request" && (t?.action ?? []).includes("review_submitted") ? "authorized by the REVIEWER's author_association (not the PR author's) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
|
|
36967
37083
|
"dedup by X-GitHub-Delivery GUID (redelivery-safe)",
|
|
36968
37084
|
where
|
|
36969
37085
|
];
|
|
@@ -37456,17 +37572,24 @@ var init_dashboard = __esm({
|
|
|
37456
37572
|
// src/setup-wizard.ts
|
|
37457
37573
|
var setup_wizard_exports = {};
|
|
37458
37574
|
__export(setup_wizard_exports, {
|
|
37575
|
+
RECEIVER_VERSION: () => RECEIVER_VERSION,
|
|
37459
37576
|
RUNTIME_VERSION: () => RUNTIME_VERSION,
|
|
37460
37577
|
detectDeployment: () => detectDeployment,
|
|
37578
|
+
dockerHint: () => dockerHint,
|
|
37461
37579
|
listRepoSkills: () => listRepoSkills,
|
|
37462
37580
|
npmInstallArgs: () => npmInstallArgs,
|
|
37581
|
+
npmInstallArgsFor: () => npmInstallArgsFor,
|
|
37463
37582
|
npmSpawnOptions: () => npmSpawnOptions,
|
|
37583
|
+
probeDocker: () => probeDocker,
|
|
37584
|
+
readInstalledVersion: () => readInstalledVersion,
|
|
37585
|
+
receiverInstallArgs: () => receiverInstallArgs,
|
|
37464
37586
|
registerNudge: () => registerNudge,
|
|
37465
37587
|
runAttached: () => runAttached,
|
|
37466
|
-
runSetupWizard: () => runSetupWizard
|
|
37588
|
+
runSetupWizard: () => runSetupWizard,
|
|
37589
|
+
runtimeDirFor: () => runtimeDirFor
|
|
37467
37590
|
});
|
|
37468
37591
|
import * as nodeFs4 from "node:fs";
|
|
37469
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
37592
|
+
import { execFileSync as execFileSync3, spawn as nodeSpawn } from "node:child_process";
|
|
37470
37593
|
import { homedir as homedir2 } from "node:os";
|
|
37471
37594
|
import { join as join6 } from "node:path";
|
|
37472
37595
|
function envIsSet(env, key) {
|
|
@@ -37545,10 +37668,10 @@ ${title}
|
|
|
37545
37668
|
const result = await custom.call(ctx.ui, factory, { overlay: true });
|
|
37546
37669
|
return result ?? { code: null, error: new Error("the overlay closed before the command finished") };
|
|
37547
37670
|
}
|
|
37548
|
-
function
|
|
37671
|
+
function npmInstallArgsFor(pkgName, version) {
|
|
37549
37672
|
return [
|
|
37550
37673
|
"install",
|
|
37551
|
-
|
|
37674
|
+
`${pkgName}@${version}`,
|
|
37552
37675
|
"--omit=dev",
|
|
37553
37676
|
"--omit=optional",
|
|
37554
37677
|
"--ignore-scripts",
|
|
@@ -37557,6 +37680,54 @@ function npmInstallArgs() {
|
|
|
37557
37680
|
"--loglevel=error"
|
|
37558
37681
|
];
|
|
37559
37682
|
}
|
|
37683
|
+
function npmInstallArgs() {
|
|
37684
|
+
return npmInstallArgsFor(RUNTIME_PKG, RUNTIME_VERSION);
|
|
37685
|
+
}
|
|
37686
|
+
function receiverInstallArgs() {
|
|
37687
|
+
return npmInstallArgsFor(RECEIVER_PKG, RECEIVER_VERSION);
|
|
37688
|
+
}
|
|
37689
|
+
function probeDocker(execFn = execFileSync3) {
|
|
37690
|
+
try {
|
|
37691
|
+
execFn("docker", ["version"], { stdio: "ignore", timeout: DOCKER_PROBE_TIMEOUT_MS });
|
|
37692
|
+
return { ok: true };
|
|
37693
|
+
} catch (err) {
|
|
37694
|
+
if (err?.code === "ENOENT" || err?.errno === "ENOENT") return { missing: true };
|
|
37695
|
+
if (typeof err?.status === "number") return { daemonDown: `\`docker version\` exited ${err.status}` };
|
|
37696
|
+
return { daemonDown: err?.message ?? String(err) };
|
|
37697
|
+
}
|
|
37698
|
+
}
|
|
37699
|
+
function dockerHint(platform) {
|
|
37700
|
+
if (platform === "darwin") {
|
|
37701
|
+
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.";
|
|
37702
|
+
}
|
|
37703
|
+
if (platform === "win32") {
|
|
37704
|
+
return "Windows: install and START Docker Desktop (docs.docker.com/desktop/install/windows-install) \u2014 it provides the `docker` CLI and a running daemon.";
|
|
37705
|
+
}
|
|
37706
|
+
return "Linux: install the docker engine from your distribution's own packages, following docs.docker.com/engine/install, then start and enable its daemon.";
|
|
37707
|
+
}
|
|
37708
|
+
async function ensureDocker(ui, notify, { platform, probeDockerFn }) {
|
|
37709
|
+
for (let round = 1; ; round++) {
|
|
37710
|
+
const probe = probeDockerFn();
|
|
37711
|
+
if (probe.ok) return true;
|
|
37712
|
+
const why = probe.missing ? "no `docker` on PATH" : `docker is installed but not answering (${probe.daemonDown})`;
|
|
37713
|
+
notify?.(`docker check: ${why}. ${dockerHint(platform)}`, "warning");
|
|
37714
|
+
if (round >= DOCKER_PROBE_ROUNDS) {
|
|
37715
|
+
notify?.(
|
|
37716
|
+
`docker still not ready after ${DOCKER_PROBE_ROUNDS} checks \u2014 stopping setup before anything was installed. Re-run /dispatch setup once \`docker version\` answers.`,
|
|
37717
|
+
"error"
|
|
37718
|
+
);
|
|
37719
|
+
return false;
|
|
37720
|
+
}
|
|
37721
|
+
const choice = await ui.select("Docker is not ready", ["Re-check", "Continue anyway", "Stop"]);
|
|
37722
|
+
if (choice === "Re-check") continue;
|
|
37723
|
+
if (choice === "Continue anyway") {
|
|
37724
|
+
notify?.("continuing without a docker answer \u2014 `up` runs its own docker checks and refuses there if it is still missing", "info");
|
|
37725
|
+
return true;
|
|
37726
|
+
}
|
|
37727
|
+
notify?.("setup stopped before anything was installed \u2014 re-run /dispatch setup when docker is ready", "info");
|
|
37728
|
+
return false;
|
|
37729
|
+
}
|
|
37730
|
+
}
|
|
37560
37731
|
function npmSpawnOptions(platform, dir) {
|
|
37561
37732
|
return platform === "win32" ? { bin: "npm.cmd", options: { cwd: dir, shell: true } } : { bin: "npm", options: { cwd: dir } };
|
|
37562
37733
|
}
|
|
@@ -37568,6 +37739,9 @@ function listRepoSkills(cwd, fs = nodeFs4) {
|
|
|
37568
37739
|
return [];
|
|
37569
37740
|
}
|
|
37570
37741
|
}
|
|
37742
|
+
function runtimeDirFor(dir, pkg = "pi-dispatch") {
|
|
37743
|
+
return join6(dir, "node_modules", "@edgehero", pkg);
|
|
37744
|
+
}
|
|
37571
37745
|
function readInstalledVersion(fs, runtimeDir) {
|
|
37572
37746
|
try {
|
|
37573
37747
|
const pkg = JSON.parse(fs.readFileSync(join6(runtimeDir, "package.json"), "utf8"));
|
|
@@ -37576,6 +37750,13 @@ function readInstalledVersion(fs, runtimeDir) {
|
|
|
37576
37750
|
return void 0;
|
|
37577
37751
|
}
|
|
37578
37752
|
}
|
|
37753
|
+
function ensureDeploymentPackageJson(fs, dir) {
|
|
37754
|
+
const rootPkg = join6(dir, "package.json");
|
|
37755
|
+
if (!fs.existsSync(rootPkg)) {
|
|
37756
|
+
fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
|
|
37757
|
+
`);
|
|
37758
|
+
}
|
|
37759
|
+
}
|
|
37579
37760
|
async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
37580
37761
|
const {
|
|
37581
37762
|
fs = nodeFs4,
|
|
@@ -37591,14 +37772,16 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37591
37772
|
writeTriggersFn = writeTriggers,
|
|
37592
37773
|
resolvePathsFn = resolvePaths,
|
|
37593
37774
|
listRepoSkillsFn = listRepoSkills,
|
|
37594
|
-
existsSyncFn = (p) => fs.existsSync(p)
|
|
37775
|
+
existsSyncFn = (p) => fs.existsSync(p),
|
|
37776
|
+
probeDockerFn = probeDocker,
|
|
37777
|
+
initialDetection
|
|
37595
37778
|
} = deps;
|
|
37596
37779
|
const ui = ctx?.ui;
|
|
37597
37780
|
if (typeof ui?.input !== "function" || typeof ui?.select !== "function" || typeof ui?.confirm !== "function") {
|
|
37598
37781
|
notify?.("setup needs dialogs (newer pi) \u2014 no input/select/confirm available", "warning");
|
|
37599
37782
|
return;
|
|
37600
37783
|
}
|
|
37601
|
-
const det = await detectFn({ env, cwd: ctx?.cwd, fs });
|
|
37784
|
+
const det = initialDetection ?? await detectFn({ env, cwd: ctx?.cwd, fs });
|
|
37602
37785
|
notify?.(`pi-dispatch setup \u2014 detected: ${det.detail}`, "info");
|
|
37603
37786
|
const intent = await ui.select("pi-dispatch setup", ["Guided setup", "Open the panel anyway", "Cancel"]);
|
|
37604
37787
|
if (intent === "Open the panel anyway") {
|
|
@@ -37615,8 +37798,9 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37615
37798
|
notify?.(`could not create ${dir}: ${err?.message ?? err} \u2014 setup cannot continue`, "error");
|
|
37616
37799
|
return;
|
|
37617
37800
|
}
|
|
37618
|
-
const runtimeDir =
|
|
37801
|
+
const runtimeDir = runtimeDirFor(dir);
|
|
37619
37802
|
const cliPath = join6(runtimeDir, "src", "cli.mjs");
|
|
37803
|
+
if (!await ensureDocker(ui, notify, { platform, probeDockerFn })) return;
|
|
37620
37804
|
if (readInstalledVersion(fs, runtimeDir) === RUNTIME_VERSION) {
|
|
37621
37805
|
notify?.(`runtime ${RUNTIME_VERSION} already installed in ${dir} \u2014 skipping npm install`, "info");
|
|
37622
37806
|
} else {
|
|
@@ -37632,11 +37816,7 @@ async function runSetupWizard(paths, ctx, notify, deps = {}) {
|
|
|
37632
37816
|
if (!okInstall) {
|
|
37633
37817
|
notify?.(`install skipped \u2014 later steps assume the runtime at ${runtimeDir}`, "info");
|
|
37634
37818
|
} else {
|
|
37635
|
-
|
|
37636
|
-
if (!fs.existsSync(rootPkg)) {
|
|
37637
|
-
fs.writeFileSync(rootPkg, `${JSON.stringify({ name: "pi-dispatch-deployment", private: true }, null, 2)}
|
|
37638
|
-
`);
|
|
37639
|
-
}
|
|
37819
|
+
ensureDeploymentPackageJson(fs, dir);
|
|
37640
37820
|
const res = await runAttachedFn(ctx, {
|
|
37641
37821
|
title: `npm install @edgehero/pi-dispatch@${RUNTIME_VERSION} \u2014 in ${dir}`,
|
|
37642
37822
|
argv0: bin,
|
|
@@ -37738,6 +37918,7 @@ ${JSON.stringify(pointer, null, 2)}`);
|
|
|
37738
37918
|
} else {
|
|
37739
37919
|
notify?.(`later: node ${cliPath} setup github (in ${dir})`, "info");
|
|
37740
37920
|
}
|
|
37921
|
+
await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn });
|
|
37741
37922
|
if (typeof ctx?.cwd === "string" && ctx.cwd !== "" && existsSyncFn(ctx.cwd)) {
|
|
37742
37923
|
await offerFirstTrigger(ctx.cwd, dir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn });
|
|
37743
37924
|
}
|
|
@@ -37747,6 +37928,110 @@ ${JSON.stringify(pointer, null, 2)}`);
|
|
|
37747
37928
|
await openDashboardFn(resolvePathsFn(env), ctx, notify);
|
|
37748
37929
|
}
|
|
37749
37930
|
}
|
|
37931
|
+
async function offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn }) {
|
|
37932
|
+
const choice = await ui.select("How should GitHub events reach the queue?", [
|
|
37933
|
+
EDGE_SERVICE,
|
|
37934
|
+
EDGE_COMPOSE,
|
|
37935
|
+
EDGE_POLL,
|
|
37936
|
+
"Skip"
|
|
37937
|
+
]);
|
|
37938
|
+
if (choice === EDGE_SERVICE) {
|
|
37939
|
+
const args = receiverInstallArgs();
|
|
37940
|
+
const { bin, options } = npmSpawnOptions(platform, dir);
|
|
37941
|
+
const unitHint = `node ${cliPath} service install --receiver (in ${dir})`;
|
|
37942
|
+
const ok = await ui.confirm(
|
|
37943
|
+
"Install the webhook receiver",
|
|
37944
|
+
`Run in ${dir}:
|
|
37945
|
+
${bin} ${args.join(" ")}
|
|
37946
|
+
|
|
37947
|
+
then, in the same folder:
|
|
37948
|
+
${execPath} ${cliPath} service install --receiver
|
|
37949
|
+
|
|
37950
|
+
(scripts disabled; installs the pinned ${RECEIVER_PKG}@${RECEIVER_VERSION}, then renders a user-level receiver unit for this host)`
|
|
37951
|
+
);
|
|
37952
|
+
if (!ok) {
|
|
37953
|
+
notify?.(`receiver skipped \u2014 later: ${bin} ${args.join(" ")} (in ${dir}), then ${unitHint}`, "info");
|
|
37954
|
+
return;
|
|
37955
|
+
}
|
|
37956
|
+
ensureDeploymentPackageJson(fs, dir);
|
|
37957
|
+
const res = await runAttachedFn(ctx, {
|
|
37958
|
+
title: `npm install ${RECEIVER_PKG}@${RECEIVER_VERSION} \u2014 in ${dir}`,
|
|
37959
|
+
argv0: bin,
|
|
37960
|
+
args,
|
|
37961
|
+
cwd: options.cwd,
|
|
37962
|
+
shell: options.shell,
|
|
37963
|
+
env
|
|
37964
|
+
});
|
|
37965
|
+
const installed = readInstalledVersion(fs, runtimeDirFor(dir, "pi-dispatch-receiver"));
|
|
37966
|
+
if (installed !== RECEIVER_VERSION) {
|
|
37967
|
+
const how = res?.error ? `npm could not run: ${res.error.message}` : `npm exited ${res?.code}`;
|
|
37968
|
+
notify?.(
|
|
37969
|
+
`${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}`,
|
|
37970
|
+
"error"
|
|
37971
|
+
);
|
|
37972
|
+
return;
|
|
37973
|
+
}
|
|
37974
|
+
await runAttachedFn(ctx, {
|
|
37975
|
+
title: `pi-dispatch service install --receiver \u2014 in ${dir}`,
|
|
37976
|
+
argv0: execPath,
|
|
37977
|
+
args: [cliPath, "service", "install", "--receiver"],
|
|
37978
|
+
cwd: dir,
|
|
37979
|
+
env
|
|
37980
|
+
});
|
|
37981
|
+
return;
|
|
37982
|
+
}
|
|
37983
|
+
if (choice === EDGE_COMPOSE) {
|
|
37984
|
+
const src = join6(runtimeDirFor(dir), "deploy", "docker-compose.yml");
|
|
37985
|
+
const dest = join6(dir, "docker-compose.yml");
|
|
37986
|
+
if (fs.existsSync(dest)) {
|
|
37987
|
+
notify?.(`${dest} already exists \u2014 keeping yours as it is (setup never overwrites a compose file you may have edited)`, "info");
|
|
37988
|
+
} else {
|
|
37989
|
+
try {
|
|
37990
|
+
fs.copyFileSync(src, dest);
|
|
37991
|
+
notify?.(`copied the runtime's compose file to ${dest}`, "info");
|
|
37992
|
+
} catch (err) {
|
|
37993
|
+
notify?.(
|
|
37994
|
+
`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)`,
|
|
37995
|
+
"error"
|
|
37996
|
+
);
|
|
37997
|
+
return;
|
|
37998
|
+
}
|
|
37999
|
+
}
|
|
38000
|
+
const args = ["compose", "-f", dest, "--profile", "receiver", "up", "-d"];
|
|
38001
|
+
const ok = await ui.confirm(
|
|
38002
|
+
"Start the receiver container",
|
|
38003
|
+
`Run in ${dir}:
|
|
38004
|
+
docker ${args.join(" ")}
|
|
38005
|
+
|
|
38006
|
+
the receiver profile reads ${join6(dir, ".env")} \u2014 WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`
|
|
38007
|
+
);
|
|
38008
|
+
if (!ok) {
|
|
38009
|
+
notify?.(`later: docker ${args.join(" ")} (in ${dir})`, "info");
|
|
38010
|
+
return;
|
|
38011
|
+
}
|
|
38012
|
+
await runAttachedFn(ctx, {
|
|
38013
|
+
title: `docker compose --profile receiver up -d \u2014 in ${dir}`,
|
|
38014
|
+
argv0: "docker",
|
|
38015
|
+
args,
|
|
38016
|
+
cwd: dir,
|
|
38017
|
+
env
|
|
38018
|
+
});
|
|
38019
|
+
return;
|
|
38020
|
+
}
|
|
38021
|
+
if (choice === EDGE_POLL) {
|
|
38022
|
+
notify?.(
|
|
38023
|
+
`polling needs no public URL, no DNS and no tunnel \u2014 two commands, in ${dir}:
|
|
38024
|
+
1) node ${cliPath} setup github --no-webhook (mints the App with its webhook INACTIVE \u2014 the polling-ready shape)
|
|
38025
|
+
2) npx @edgehero/pi-dispatch-receiver poll (the producer; run it from the deployment folder, under whatever keeps it alive)`,
|
|
38026
|
+
"info"
|
|
38027
|
+
);
|
|
38028
|
+
return;
|
|
38029
|
+
}
|
|
38030
|
+
notify?.(
|
|
38031
|
+
`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.`,
|
|
38032
|
+
"info"
|
|
38033
|
+
);
|
|
38034
|
+
}
|
|
37750
38035
|
async function offerFirstTrigger(repoCwd, deployDir, ui, notify, { fs, listRepoSkillsFn, writeTriggersFn }) {
|
|
37751
38036
|
const offer = await ui.select("First trigger", ["A cron trigger for this repo", "Skip"]);
|
|
37752
38037
|
if (offer !== "A cron trigger for this repo") return;
|
|
@@ -37806,23 +38091,40 @@ function registerNudge(pi, deps = {}) {
|
|
|
37806
38091
|
}
|
|
37807
38092
|
});
|
|
37808
38093
|
}
|
|
37809
|
-
var RUNTIME_VERSION, NUDGE_MARKER_BASENAME, CWD_SCAFFOLD_FILES, ENV_PATH_KEYS, CRON_ID_RE;
|
|
38094
|
+
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
38095
|
var init_setup_wizard = __esm({
|
|
37811
38096
|
"src/setup-wizard.ts"() {
|
|
37812
38097
|
init_flow_gate();
|
|
37813
38098
|
init_deployment_pointer();
|
|
37814
38099
|
init_read_model();
|
|
37815
38100
|
init_index();
|
|
37816
|
-
RUNTIME_VERSION = "0.
|
|
38101
|
+
RUNTIME_VERSION = "0.2.0";
|
|
38102
|
+
RECEIVER_VERSION = "0.2.0";
|
|
38103
|
+
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
38104
|
+
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
37817
38105
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
37818
38106
|
CWD_SCAFFOLD_FILES = [".env", "triggers.json", "pause-windows.json", "subscriptions.json"];
|
|
37819
38107
|
ENV_PATH_KEYS = POINTER_ENV_ALLOWLIST.filter((key) => key !== "VALKEY_URL");
|
|
38108
|
+
DOCKER_PROBE_ROUNDS = 5;
|
|
38109
|
+
DOCKER_PROBE_TIMEOUT_MS = 1e4;
|
|
37820
38110
|
CRON_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
38111
|
+
EDGE_SERVICE = "Install the webhook receiver as a service";
|
|
38112
|
+
EDGE_COMPOSE = "Run the receiver with docker compose";
|
|
38113
|
+
EDGE_POLL = "Show the polling command";
|
|
37821
38114
|
}
|
|
37822
38115
|
});
|
|
37823
38116
|
|
|
37824
38117
|
// src/index.ts
|
|
38118
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
37825
38119
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
38120
|
+
import * as nodeFs5 from "node:fs";
|
|
38121
|
+
function computePiVersionAdvisory(version, supported) {
|
|
38122
|
+
if (version === supported || version === "0.0.0") return void 0;
|
|
38123
|
+
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`;
|
|
38124
|
+
}
|
|
38125
|
+
function _setPiVersionAdvisoryForTests(message) {
|
|
38126
|
+
piVersionAdvisory = message;
|
|
38127
|
+
}
|
|
37826
38128
|
function admin(pi) {
|
|
37827
38129
|
for (const member of USED_API) {
|
|
37828
38130
|
if (typeof pi[member] !== "function") {
|
|
@@ -37832,6 +38134,10 @@ function admin(pi) {
|
|
|
37832
38134
|
return;
|
|
37833
38135
|
}
|
|
37834
38136
|
}
|
|
38137
|
+
if (!piVersionCheckDone) {
|
|
38138
|
+
piVersionCheckDone = true;
|
|
38139
|
+
piVersionAdvisory = computePiVersionAdvisory(VERSION, SUPPORTED_PI_VERSION);
|
|
38140
|
+
}
|
|
37835
38141
|
try {
|
|
37836
38142
|
applyDeploymentPointer();
|
|
37837
38143
|
} catch {
|
|
@@ -37995,7 +38301,7 @@ function registerTools(pi) {
|
|
|
37995
38301
|
pi.registerTool({
|
|
37996
38302
|
name: "dispatch_trigger_add",
|
|
37997
38303
|
label: "pi-dispatch add trigger",
|
|
37998
|
-
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened, gitlab is open|update|reopen|approved. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
38304
|
+
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
37999
38305
|
executionMode: "sequential",
|
|
38000
38306
|
parameters: typebox_exports.Object({
|
|
38001
38307
|
kind: typebox_exports.String(),
|
|
@@ -38262,22 +38568,28 @@ async function dispatch(pi, args, ctx) {
|
|
|
38262
38568
|
setGlyphs(paths.asciiGlyphs);
|
|
38263
38569
|
const pnote = takePointerNotice();
|
|
38264
38570
|
if (pnote) notify?.(pnote, "warning");
|
|
38571
|
+
if (piVersionAdvisory) {
|
|
38572
|
+
notify?.(piVersionAdvisory, "info");
|
|
38573
|
+
piVersionAdvisory = void 0;
|
|
38574
|
+
}
|
|
38265
38575
|
if (sub === "") {
|
|
38266
|
-
const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
|
|
38576
|
+
const { detectDeployment: detectDeployment2, runSetupWizard: runSetupWizard2, readInstalledVersion: readInstalledVersion2, runtimeDirFor: runtimeDirFor2, RUNTIME_VERSION: RUNTIME_VERSION2 } = await Promise.resolve().then(() => (init_setup_wizard(), setup_wizard_exports));
|
|
38267
38577
|
const det = await detectDeployment2({ env: process.env, cwd: ctx?.cwd });
|
|
38268
38578
|
if (det.state === "none") {
|
|
38269
|
-
|
|
38270
|
-
|
|
38271
|
-
|
|
38272
|
-
|
|
38579
|
+
await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard, initialDetection: det });
|
|
38580
|
+
return;
|
|
38581
|
+
}
|
|
38582
|
+
if (det.state === "pointer" && !runtimeSkewNoticed) {
|
|
38583
|
+
const ptrRes = readPointer({ path: pointerPath(process.env) });
|
|
38584
|
+
const deploymentDir = ptrRes.pointer?.deploymentDir;
|
|
38585
|
+
const installed = deploymentDir ? readInstalledVersion2(nodeFs5, runtimeDirFor2(deploymentDir)) : void 0;
|
|
38586
|
+
if (typeof installed === "string" && installed !== RUNTIME_VERSION2) {
|
|
38587
|
+
runtimeSkewNoticed = true;
|
|
38588
|
+
notify?.(
|
|
38589
|
+
`deployment runtime ${installed}, this console pins ${RUNTIME_VERSION2} \u2014 run /dispatch setup to upgrade`,
|
|
38590
|
+
"warning"
|
|
38273
38591
|
);
|
|
38274
|
-
if (ok) {
|
|
38275
|
-
await runSetupWizard2(paths, ctx, notify, { openDashboardFn: openDashboard });
|
|
38276
|
-
return;
|
|
38277
|
-
}
|
|
38278
38592
|
}
|
|
38279
|
-
notify?.(`${USAGE} \u2014 run /dispatch setup when you are ready`, "info");
|
|
38280
|
-
return;
|
|
38281
38593
|
}
|
|
38282
38594
|
if (det.state === "cwd" || det.state === "reachable") {
|
|
38283
38595
|
notify?.(`using ${det.detail} \u2014 /dispatch setup can write a deployment pointer so this works from anywhere`, "info");
|
|
@@ -38846,7 +39158,7 @@ function completeArguments(prefix) {
|
|
|
38846
39158
|
}
|
|
38847
39159
|
return null;
|
|
38848
39160
|
}
|
|
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;
|
|
39161
|
+
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
39162
|
var init_index = __esm({
|
|
38851
39163
|
"src/index.ts"() {
|
|
38852
39164
|
init_build();
|
|
@@ -38863,6 +39175,8 @@ var init_index = __esm({
|
|
|
38863
39175
|
init_keys2();
|
|
38864
39176
|
USED_API = ["registerCommand", "registerTool", "sendMessage", "on"];
|
|
38865
39177
|
SUPPORTED_PI_VERSION = "0.80.7";
|
|
39178
|
+
piVersionCheckDone = false;
|
|
39179
|
+
runtimeSkewNoticed = false;
|
|
38866
39180
|
CHANNEL = "pi-dispatch-admin";
|
|
38867
39181
|
REBUILT_NOTICE = (reason) => `replaced invalid settings file (${reason}) \u2014 other keys were lost`;
|
|
38868
39182
|
USAGE = "usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
|
|
@@ -38883,7 +39197,7 @@ var init_index = __esm({
|
|
|
38883
39197
|
];
|
|
38884
39198
|
FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
|
|
38885
39199
|
PR_ACTION_VOCAB = {
|
|
38886
|
-
github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
|
|
39200
|
+
github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
|
|
38887
39201
|
gitlab: { hint: "open update reopen approved", dflt: "update" },
|
|
38888
39202
|
forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
|
|
38889
39203
|
azure: { hint: "created updated", dflt: "updated" }
|
|
@@ -38899,7 +39213,9 @@ init_index();
|
|
|
38899
39213
|
export {
|
|
38900
39214
|
SUPPORTED_PI_VERSION,
|
|
38901
39215
|
USED_API,
|
|
39216
|
+
_setPiVersionAdvisoryForTests,
|
|
38902
39217
|
buildTriggerEntry,
|
|
39218
|
+
computePiVersionAdvisory,
|
|
38903
39219
|
admin as default,
|
|
38904
39220
|
handleDashboardAction
|
|
38905
39221
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -128,9 +128,14 @@ trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `
|
|
|
128
128
|
Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
|
|
129
129
|
|
|
130
130
|
- **`pull_request` actions are the forge's own words.** GitHub takes
|
|
131
|
-
`labeled | opened | synchronize | reopened`; GitLab takes
|
|
131
|
+
`labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
|
|
132
|
+
`open | update | reopen | approved`. A word
|
|
132
133
|
from the wrong forge is refused when the file is written. It would not break anything otherwise — it
|
|
133
134
|
would simply never match an event, and the trigger would look configured while doing nothing.
|
|
135
|
+
`review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
|
|
136
|
+
add `reviewState` (`approved | changes_requested | commented`) to narrow which verdicts are worth paying
|
|
137
|
+
for. That field is github-only and legal only beside `review_submitted`; anywhere else it refuses at
|
|
138
|
+
load, because a narrowing that cannot apply reads as one that does.
|
|
134
139
|
- **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
|
|
135
140
|
are fine.
|
|
136
141
|
|