@edgehero/pi-dispatch-admin 0.8.0 → 1.1.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 +7 -7
- package/dist/index.mjs +378 -63
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +29 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-dispatch: run the pi coding agent as a self-hosted service
|
|
6
6
|
|
|
7
|
-
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you.
|
|
7
|
+
**Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
|
|
8
8
|
|
|
9
9
|
> This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
|
|
10
10
|
|
|
@@ -46,28 +46,28 @@ Every trigger is one `{ on, run }` entry in a single `triggers.json`, read live
|
|
|
46
46
|
|
|
47
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
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.
|
|
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. `run.command` is the second entry point: instead of a flow, a trigger may name a registered command a staged extension provides (`"command": "wf run nightly"`), and the job's whole prompt is that dispatch line, handled by the extension with no model turn in between. It picks which vetted command dispatches, never what code runs, and it is never AI-triggerable: job chaining refuses any request naming one, and `dispatch_run` cannot express one.
|
|
50
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
|
|
51
|
+
**How a workflow gets triggered, in one line:** `label / comment / PR / cron` fires **one job in one container**, that job runs `run.flow` or `run.command`, and the entry point drives whatever stages follow. Four consequences worth knowing before you build on it. A trigger names a **flow or a registered command, never a workflow**, so which stages run is decided by what that entry point does. A job is **not an interactive session** (the container hands pi one assembled prompt and reads the exit line), and that one prompt can be the command itself: `run.command` dispatches a workflow extension's slash command headlessly, while in a flow 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).
|
|
52
52
|
|
|
53
53
|
## The console: `/dispatch`
|
|
54
54
|
|
|
55
55
|
One command puts a live TUI over the whole deployment:
|
|
56
56
|
|
|
57
57
|
<p align="center">
|
|
58
|
-
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=0.
|
|
58
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=1.0.0" alt="The /dispatch panel: status, spend meters, triggers (including a command trigger shown as its /name), pause windows, runs, and settings" width="820">
|
|
59
59
|
</p>
|
|
60
60
|
|
|
61
61
|
- **Status and spend.** Queue and worker state, day/week/month spend meters, a daily token counter, and a run-history table with per-job tokens and cost.
|
|
62
62
|
- **Insights, the one analytics page.** Press `i` on the panel (or type `/dispatch insights`) and a single self contained page opens in your browser: the budget dials (the one lever that actually changes what all of this costs), per-plan verdicts against API rates, daily, cumulative and per-flow spend charts, the four breakdowns (flow, trigger, model, repo), and the whole trigger and flow topology with spend badged onto the triggers that earned it. Every dollar carries its class: a plan-covered run never renders as $0.00, and an estimate is always marked as one. Design your agent loops as triggers and skills, see the loops you actually built, and see what each one costs, all in one place ([`docs/insights.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/insights.md)).
|
|
63
63
|
|
|
64
64
|
<p align="center">
|
|
65
|
-
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/insights-view.png?v=0.
|
|
65
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/insights-view.png?v=1.0.0" alt="The insights page: KPI tiles, a plan verdict card, the daily spend chart, the four breakdowns with plan-covered buckets drawn as chips instead of dollar bars, and the topology with spend badges and tier-resolved flows" width="820">
|
|
66
66
|
</p>
|
|
67
67
|
|
|
68
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.
|
|
69
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.
|
|
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.
|
|
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). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. 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.
|
|
71
71
|
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
|
|
72
72
|
|
|
73
73
|
## Install
|
|
@@ -80,7 +80,7 @@ pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
|
|
|
80
80
|
|
|
81
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.
|
|
82
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.
|
|
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. No allowlist reaches commands either: a `run.command` fires from the reviewed triggers file only.
|
|
84
84
|
|
|
85
85
|
## Get the whole thing
|
|
86
86
|
|
package/dist/index.mjs
CHANGED
|
@@ -7598,6 +7598,12 @@ var init_build = __esm({
|
|
|
7598
7598
|
}
|
|
7599
7599
|
});
|
|
7600
7600
|
|
|
7601
|
+
// ../worker/src/egress.mjs
|
|
7602
|
+
var init_egress = __esm({
|
|
7603
|
+
"../worker/src/egress.mjs"() {
|
|
7604
|
+
}
|
|
7605
|
+
});
|
|
7606
|
+
|
|
7601
7607
|
// ../worker/src/forges.mjs
|
|
7602
7608
|
function isForgeKind(kind) {
|
|
7603
7609
|
return typeof kind === "string" && Object.hasOwn(FORGES, kind);
|
|
@@ -7698,6 +7704,7 @@ function defaultSettingsFile() {
|
|
|
7698
7704
|
var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
|
|
7699
7705
|
var init_config2 = __esm({
|
|
7700
7706
|
"../worker/src/config.mjs"() {
|
|
7707
|
+
init_egress();
|
|
7701
7708
|
init_forges();
|
|
7702
7709
|
CHAIN_DEPTH_MAX_DEFAULT = 1;
|
|
7703
7710
|
CHAIN_MAX_PER_JOB_DEFAULT = 2;
|
|
@@ -7927,13 +7934,14 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7927
7934
|
if (fieldCount !== 5 && fieldCount !== 6) {
|
|
7928
7935
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
7929
7936
|
}
|
|
7937
|
+
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
7930
7938
|
if (!isNonEmptyString2(run.folder)) {
|
|
7931
7939
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
7932
7940
|
}
|
|
7933
|
-
if (!isNonEmptyString2(run.flow)) {
|
|
7934
|
-
throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
|
|
7941
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
7942
|
+
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
7935
7943
|
}
|
|
7936
|
-
if (!isNonEmptyString2(run.task)) {
|
|
7944
|
+
if (command === void 0 && !isNonEmptyString2(run.task)) {
|
|
7937
7945
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
7938
7946
|
}
|
|
7939
7947
|
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
@@ -7947,7 +7955,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7947
7955
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
7948
7956
|
return {
|
|
7949
7957
|
on: { type: "cron", id, pattern },
|
|
7950
|
-
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 } }
|
|
7958
|
+
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, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir } }
|
|
7951
7959
|
};
|
|
7952
7960
|
}
|
|
7953
7961
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -8006,6 +8014,35 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
|
8006
8014
|
}
|
|
8007
8015
|
return text;
|
|
8008
8016
|
}
|
|
8017
|
+
function validateCommand(run, at, path, { onType }) {
|
|
8018
|
+
const raw = run.command;
|
|
8019
|
+
if (run.flow !== void 0 && raw !== void 0) {
|
|
8020
|
+
throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
|
|
8021
|
+
}
|
|
8022
|
+
if (raw === void 0) return void 0;
|
|
8023
|
+
if (!isNonEmptyString2(raw)) {
|
|
8024
|
+
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
8025
|
+
}
|
|
8026
|
+
if (raw !== raw.trim()) {
|
|
8027
|
+
throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
|
|
8028
|
+
}
|
|
8029
|
+
if (raw.startsWith("/")) {
|
|
8030
|
+
throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
|
|
8031
|
+
}
|
|
8032
|
+
if (/[\u0000-\u001F\u007F]/.test(raw)) {
|
|
8033
|
+
throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
|
|
8034
|
+
}
|
|
8035
|
+
if (onType === "cron" && run.task !== void 0) {
|
|
8036
|
+
throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
|
|
8037
|
+
}
|
|
8038
|
+
if (onType !== "cron" && run.instructions !== void 0) {
|
|
8039
|
+
throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
|
|
8040
|
+
}
|
|
8041
|
+
if (run.resume === true) {
|
|
8042
|
+
throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
|
|
8043
|
+
}
|
|
8044
|
+
return raw;
|
|
8045
|
+
}
|
|
8009
8046
|
function validatePredicate(on, index, path, requirePositive) {
|
|
8010
8047
|
const at = `trigger at index ${index}`;
|
|
8011
8048
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -8041,9 +8078,6 @@ function validateReplicas(run, at, path) {
|
|
|
8041
8078
|
if (run.kind === "local") {
|
|
8042
8079
|
throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
|
|
8043
8080
|
}
|
|
8044
|
-
if (run.kind !== "github") {
|
|
8045
|
-
throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
|
|
8046
|
-
}
|
|
8047
8081
|
if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
|
|
8048
8082
|
throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
|
|
8049
8083
|
}
|
|
@@ -8055,8 +8089,9 @@ function validateReplicas(run, at, path) {
|
|
|
8055
8089
|
function normalizeLabel(on, run, index, path) {
|
|
8056
8090
|
const at = `trigger at index ${index}`;
|
|
8057
8091
|
const predicate = validatePredicate(on, index, path, true);
|
|
8058
|
-
|
|
8059
|
-
|
|
8092
|
+
const command = validateCommand(run, at, path, { onType: "label" });
|
|
8093
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8094
|
+
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8060
8095
|
}
|
|
8061
8096
|
const packages = validatePackagesFlag(run, at, path);
|
|
8062
8097
|
const image = validateImageRef(run, at, path);
|
|
@@ -8067,7 +8102,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8067
8102
|
const replicas = validateReplicas(run, at, path);
|
|
8068
8103
|
return {
|
|
8069
8104
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
8070
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8105
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8071
8106
|
};
|
|
8072
8107
|
}
|
|
8073
8108
|
function normalizeComment(on, run, index, path, state) {
|
|
@@ -8075,8 +8110,9 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8075
8110
|
if (!isNonEmptyString2(on.phrase)) {
|
|
8076
8111
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
8077
8112
|
}
|
|
8078
|
-
|
|
8079
|
-
|
|
8113
|
+
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
8114
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8115
|
+
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
8080
8116
|
}
|
|
8081
8117
|
state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
|
|
8082
8118
|
if (state.commentCounts[run.kind] > 1) {
|
|
@@ -8091,7 +8127,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8091
8127
|
const replicas = validateReplicas(run, at, path);
|
|
8092
8128
|
return {
|
|
8093
8129
|
on: { type: "comment", phrase: on.phrase },
|
|
8094
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8130
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository } }
|
|
8095
8131
|
};
|
|
8096
8132
|
}
|
|
8097
8133
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -8114,8 +8150,9 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8114
8150
|
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
8115
8151
|
}
|
|
8116
8152
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
8117
|
-
|
|
8118
|
-
|
|
8153
|
+
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
8154
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8155
|
+
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8119
8156
|
}
|
|
8120
8157
|
const packages = validatePackagesFlag(run, at, path);
|
|
8121
8158
|
const image = validateImageRef(run, at, path);
|
|
@@ -8135,7 +8172,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8135
8172
|
all: predicate.all,
|
|
8136
8173
|
none: predicate.none
|
|
8137
8174
|
},
|
|
8138
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
|
|
8175
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
|
|
8139
8176
|
};
|
|
8140
8177
|
}
|
|
8141
8178
|
function validateReviewState(on, actions, run, at, path) {
|
|
@@ -8516,14 +8553,18 @@ import { Queue } from "bullmq";
|
|
|
8516
8553
|
function makeQueue(connection) {
|
|
8517
8554
|
return new Queue(QUEUE, { connection });
|
|
8518
8555
|
}
|
|
8519
|
-
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8556
|
+
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8520
8557
|
const minute = now.toISOString().slice(0, 16);
|
|
8521
|
-
const id = jobId ?? localJobId({ folder, flow, task, minute });
|
|
8558
|
+
const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
|
|
8522
8559
|
const data = {
|
|
8523
8560
|
kind: "local",
|
|
8524
8561
|
folder,
|
|
8525
8562
|
flow,
|
|
8526
8563
|
task,
|
|
8564
|
+
// The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
|
|
8565
|
+
// `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
|
|
8566
|
+
// job carrying it has flow/task undefined, which JSON serialization drops.
|
|
8567
|
+
...command !== void 0 && { command },
|
|
8527
8568
|
provider,
|
|
8528
8569
|
model,
|
|
8529
8570
|
maxTurns,
|
|
@@ -8663,12 +8704,15 @@ function buildDockerRunArgs({
|
|
|
8663
8704
|
name,
|
|
8664
8705
|
memory = "4g",
|
|
8665
8706
|
cpus = "2",
|
|
8707
|
+
network = null,
|
|
8666
8708
|
extraFlags = []
|
|
8667
8709
|
}) {
|
|
8668
8710
|
if (!image) throw new Error("docker run: image is required");
|
|
8669
8711
|
if (!name) throw new Error("docker run: container name is required");
|
|
8670
8712
|
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
8671
|
-
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}
|
|
8713
|
+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
|
|
8714
|
+
if (network) args.push(`--network=${network}`);
|
|
8715
|
+
args.push(...extraFlags);
|
|
8672
8716
|
for (const [k, v] of Object.entries(env ?? {})) {
|
|
8673
8717
|
if (v === void 0 || v === null) continue;
|
|
8674
8718
|
args.push("-e", `${k}=${v}`);
|
|
@@ -8714,8 +8758,8 @@ var init_docker_run = __esm({
|
|
|
8714
8758
|
});
|
|
8715
8759
|
|
|
8716
8760
|
// ../worker/src/packages.mjs
|
|
8717
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
8718
|
-
import { join } from "node:path";
|
|
8761
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8762
|
+
import { basename, dirname as dirname2, join, resolve } from "node:path";
|
|
8719
8763
|
function isNonEmptyString5(value) {
|
|
8720
8764
|
return typeof value === "string" && value.trim() !== "";
|
|
8721
8765
|
}
|
|
@@ -8739,6 +8783,55 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
8739
8783
|
return null;
|
|
8740
8784
|
}
|
|
8741
8785
|
}
|
|
8786
|
+
function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
|
|
8787
|
+
const out = { skills: [], unenumerable: [] };
|
|
8788
|
+
const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
|
|
8789
|
+
if (!manifest) return out;
|
|
8790
|
+
for (const pkg of manifest.packages) {
|
|
8791
|
+
const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
|
|
8792
|
+
let sources;
|
|
8793
|
+
try {
|
|
8794
|
+
const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
|
|
8795
|
+
const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
|
|
8796
|
+
if (pi) {
|
|
8797
|
+
const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
|
|
8798
|
+
if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
|
|
8799
|
+
out.unenumerable.push(pkg.name);
|
|
8800
|
+
continue;
|
|
8801
|
+
}
|
|
8802
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
|
|
8803
|
+
} else {
|
|
8804
|
+
sources = [join(root, "skills")];
|
|
8805
|
+
}
|
|
8806
|
+
} catch {
|
|
8807
|
+
continue;
|
|
8808
|
+
}
|
|
8809
|
+
for (const source of sources) {
|
|
8810
|
+
for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
|
|
8811
|
+
out.skills.push({ name: basename(dirname2(skillFile)), package: pkg.name, dir: pkg.dir });
|
|
8812
|
+
}
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
return out;
|
|
8816
|
+
}
|
|
8817
|
+
function walkSkillFiles(source, fileExists, readDir, depth = 3) {
|
|
8818
|
+
if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
|
|
8819
|
+
const found = [];
|
|
8820
|
+
const own = join(source, "SKILL.md");
|
|
8821
|
+
if (fileExists(own)) found.push(own);
|
|
8822
|
+
if (depth === 0) return found;
|
|
8823
|
+
let children;
|
|
8824
|
+
try {
|
|
8825
|
+
children = readDir(source, { withFileTypes: true });
|
|
8826
|
+
} catch {
|
|
8827
|
+
return found;
|
|
8828
|
+
}
|
|
8829
|
+
for (const child of children) {
|
|
8830
|
+
if (!child.isDirectory?.()) continue;
|
|
8831
|
+
found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
|
|
8832
|
+
}
|
|
8833
|
+
return found;
|
|
8834
|
+
}
|
|
8742
8835
|
var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
|
|
8743
8836
|
var init_packages = __esm({
|
|
8744
8837
|
"../worker/src/packages.mjs"() {
|
|
@@ -8925,11 +9018,13 @@ function findLoopHints(text) {
|
|
|
8925
9018
|
}
|
|
8926
9019
|
return hints;
|
|
8927
9020
|
}
|
|
8928
|
-
function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
|
|
9021
|
+
function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, overlaySkills, stagedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
|
|
8929
9022
|
const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
|
|
8930
9023
|
const schedulerList = Array.isArray(schedulers) ? schedulers : [];
|
|
8931
9024
|
const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
|
|
8932
9025
|
const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
|
|
9026
|
+
const overlay = overlaySkills && typeof overlaySkills === "object" && Array.isArray(overlaySkills.skills) ? overlaySkills : null;
|
|
9027
|
+
const staged = stagedSkills && typeof stagedSkills === "object" && Array.isArray(stagedSkills.skills) ? stagedSkills : null;
|
|
8933
9028
|
const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
|
|
8934
9029
|
const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
|
|
8935
9030
|
const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
|
|
@@ -8955,13 +9050,18 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
8955
9050
|
// collectGraphInputs' cap flag rides its spread into this input (review finding: this was
|
|
8956
9051
|
// hardcoded false, so the cap-reached banner could never fire on any surface).
|
|
8957
9052
|
folders: foldersTruncated === true,
|
|
8958
|
-
skills: Object.values(folders).some((f) => f?.truncated === true),
|
|
9053
|
+
skills: Object.values(folders).some((f) => f?.truncated === true) || overlay?.truncated === true || staged?.truncated === true,
|
|
8959
9054
|
edges: chainEdges?.truncated === true
|
|
8960
9055
|
},
|
|
8961
9056
|
droppedObservedEdges: 0,
|
|
8962
9057
|
// Injected dirs whose readdir failed (review finding): the OQ-022 badge is the one fact the
|
|
8963
9058
|
// injected enumeration exists for, and an unreadable dir must not make it silently vanish.
|
|
8964
|
-
injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort()
|
|
9059
|
+
injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort(),
|
|
9060
|
+
// The two tier-honesty counters (issue #188): an overlay whose skills/ failed to read, and the
|
|
9061
|
+
// staged packages whose pattern manifests defeat enumeration. Both feed the softened state
|
|
9062
|
+
// below AND a banner, because a tier the ladder silently skipped would read as "checked".
|
|
9063
|
+
overlayUnreachable: overlay?.unreachable === "unreadable",
|
|
9064
|
+
stagedUnenumerable: (Array.isArray(staged?.unenumerable) ? staged.unenumerable : []).filter((p) => typeof p === "string").sort()
|
|
8965
9065
|
}
|
|
8966
9066
|
};
|
|
8967
9067
|
const folderByPath = /* @__PURE__ */ new Map();
|
|
@@ -9034,6 +9134,28 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9034
9134
|
}
|
|
9035
9135
|
}
|
|
9036
9136
|
}
|
|
9137
|
+
const overlayNodeId = /* @__PURE__ */ new Map();
|
|
9138
|
+
if (overlay) {
|
|
9139
|
+
for (const skill of overlay.skills) {
|
|
9140
|
+
if (typeof skill?.name !== "string" || skill.name === "" || overlayNodeId.has(skill.name)) continue;
|
|
9141
|
+
const id = `overlay:${skill.name}`;
|
|
9142
|
+
overlayNodeId.set(skill.name, id);
|
|
9143
|
+
model.nodes.push({ id, kind: "overlay", name: skill.name });
|
|
9144
|
+
}
|
|
9145
|
+
}
|
|
9146
|
+
const stagedNodeId = /* @__PURE__ */ new Map();
|
|
9147
|
+
if (staged) {
|
|
9148
|
+
const seenStagedIds = /* @__PURE__ */ new Set();
|
|
9149
|
+
for (const skill of staged.skills) {
|
|
9150
|
+
if (typeof skill?.name !== "string" || skill.name === "" || typeof skill?.dir !== "string" || skill.dir === "") continue;
|
|
9151
|
+
const id = `staged:${skill.dir}:${skill.name}`;
|
|
9152
|
+
if (!seenStagedIds.has(id)) {
|
|
9153
|
+
seenStagedIds.add(id);
|
|
9154
|
+
model.nodes.push({ id, kind: "staged", name: skill.name, package: typeof skill.package === "string" && skill.package !== "" ? skill.package : skill.dir });
|
|
9155
|
+
}
|
|
9156
|
+
if (!stagedNodeId.has(skill.name)) stagedNodeId.set(skill.name, id);
|
|
9157
|
+
}
|
|
9158
|
+
}
|
|
9037
9159
|
const missingNode = (group, name) => {
|
|
9038
9160
|
const key = `${group.key} ${name}`;
|
|
9039
9161
|
let node = skillNode.get(key);
|
|
@@ -9044,6 +9166,14 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9044
9166
|
group.skillIds.push(node.id);
|
|
9045
9167
|
return node;
|
|
9046
9168
|
};
|
|
9169
|
+
const notAtHeadNode = (group, name, tiers) => {
|
|
9170
|
+
const node = missingNode(group, name);
|
|
9171
|
+
if (node.kind === "skill-missing") node.kind = "skill-not-at-head";
|
|
9172
|
+
if (node.kind === "skill-not-at-head") {
|
|
9173
|
+
node.tiersUnknown = [.../* @__PURE__ */ new Set([...Array.isArray(node.tiersUnknown) ? node.tiersUnknown : [], ...tiers])].sort();
|
|
9174
|
+
}
|
|
9175
|
+
return node;
|
|
9176
|
+
};
|
|
9047
9177
|
for (const t of triggerList2) {
|
|
9048
9178
|
if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
|
|
9049
9179
|
const id = `trigger:${t.index}`;
|
|
@@ -9080,6 +9210,11 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9080
9210
|
pattern: isCron ? t.pattern ?? null : null,
|
|
9081
9211
|
label: triggerMatchLabel(t),
|
|
9082
9212
|
flow: t.flow ?? null,
|
|
9213
|
+
// A command trigger's dispatched extension command (issue #189), flow's mutually-exclusive
|
|
9214
|
+
// sibling. Carried on the node so the display half (issue #188) has the fact to render; nothing
|
|
9215
|
+
// in THIS module consumes it -- a command names no SKILL.md, so the config-edge machinery below
|
|
9216
|
+
// (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
|
|
9217
|
+
command: t.command ?? null,
|
|
9083
9218
|
replicas: t.replicas ?? null,
|
|
9084
9219
|
folderKey: group?.key ?? null,
|
|
9085
9220
|
runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
|
|
@@ -9110,9 +9245,41 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9110
9245
|
existing.isFlow = true;
|
|
9111
9246
|
model.edges.push({ from: id, to: existing.id, kind: "config" });
|
|
9112
9247
|
} else {
|
|
9113
|
-
const
|
|
9114
|
-
|
|
9115
|
-
|
|
9248
|
+
const probes = [];
|
|
9249
|
+
if (typeof t.skillsDir === "string" && t.skillsDir !== "") {
|
|
9250
|
+
const r = injected[t.skillsDir];
|
|
9251
|
+
if (!r || typeof r.unreachable === "string") probes.push({ tier: TIER_INJECTED, state: "unknown" });
|
|
9252
|
+
else if ((Array.isArray(r.skills) ? r.skills : []).some((s) => s?.name === t.flow)) probes.push({ tier: TIER_INJECTED, state: "hit", nodeId: `injected:${t.skillsDir}:${t.flow}` });
|
|
9253
|
+
else probes.push({ tier: TIER_INJECTED, state: r.truncated === true ? "unknown" : "miss" });
|
|
9254
|
+
}
|
|
9255
|
+
if (overlay === null || typeof overlay.unreachable === "string") probes.push({ tier: TIER_OVERLAY, state: "unknown" });
|
|
9256
|
+
else if (overlayNodeId.has(t.flow)) probes.push({ tier: TIER_OVERLAY, state: "hit", nodeId: overlayNodeId.get(t.flow) });
|
|
9257
|
+
else probes.push({ tier: TIER_OVERLAY, state: overlay.truncated === true ? "unknown" : "miss" });
|
|
9258
|
+
if (t.packages === false) {
|
|
9259
|
+
probes.push({ tier: `${TIER_STAGED} (withheld: run.packages false)`, state: "miss" });
|
|
9260
|
+
} else if (staged === null) probes.push({ tier: TIER_STAGED, state: "unknown" });
|
|
9261
|
+
else if (stagedNodeId.has(t.flow)) probes.push({ tier: TIER_STAGED, state: "hit", nodeId: stagedNodeId.get(t.flow) });
|
|
9262
|
+
else probes.push({ tier: TIER_STAGED, state: staged.unenumerable.length > 0 || staged.truncated === true ? "unknown" : "miss" });
|
|
9263
|
+
let target = null;
|
|
9264
|
+
let blocked = false;
|
|
9265
|
+
for (const probe of probes) {
|
|
9266
|
+
if (probe.state === "hit" && !blocked) {
|
|
9267
|
+
target = probe.nodeId;
|
|
9268
|
+
break;
|
|
9269
|
+
}
|
|
9270
|
+
if (probe.state === "unknown") blocked = true;
|
|
9271
|
+
}
|
|
9272
|
+
if (target !== null) {
|
|
9273
|
+
model.edges.push({ from: id, to: target, kind: "config" });
|
|
9274
|
+
} else if (probes.some((p) => p.state === "unknown")) {
|
|
9275
|
+
const soft = notAtHeadNode(group, t.flow, probes.filter((p) => p.state === "unknown").map((p) => p.tier));
|
|
9276
|
+
model.edges.push({ from: id, to: soft.id, kind: "config" });
|
|
9277
|
+
} else {
|
|
9278
|
+
const target2 = missingNode(group, t.flow);
|
|
9279
|
+
model.edges.push({ from: id, to: target2.id, kind: "config" });
|
|
9280
|
+
const alsoChecked = probes.length > 0 ? `; also checked: ${probes.map((p) => p.tier).join(", ")}` : "";
|
|
9281
|
+
model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD${alsoChecked}` });
|
|
9282
|
+
}
|
|
9116
9283
|
}
|
|
9117
9284
|
} else if (group) {
|
|
9118
9285
|
const target = missingNode(group, t.flow);
|
|
@@ -9215,7 +9382,7 @@ function clipName(name) {
|
|
|
9215
9382
|
const s = String(name);
|
|
9216
9383
|
return s.length > 64 ? `${s.slice(0, 64)}\u2026` : s;
|
|
9217
9384
|
}
|
|
9218
|
-
var FRONTMATTER_BLOCK_RE, META_VALUE_MAX_CHARS, CHAIN_VOCAB_RE, CHAIN_VOCAB_RADIUS, LOOP_HINT_RE, LOOP_HINT_MAX, GRAPH_EDGE_KINDS, GRAPH_FLAGS;
|
|
9385
|
+
var FRONTMATTER_BLOCK_RE, META_VALUE_MAX_CHARS, CHAIN_VOCAB_RE, CHAIN_VOCAB_RADIUS, LOOP_HINT_RE, LOOP_HINT_MAX, GRAPH_EDGE_KINDS, GRAPH_FLAGS, GRAPH_NODE_KINDS, TIER_INJECTED, TIER_OVERLAY, TIER_STAGED;
|
|
9219
9386
|
var init_graph_model = __esm({
|
|
9220
9387
|
"src/graph-model.mjs"() {
|
|
9221
9388
|
init_flow_gate();
|
|
@@ -9228,7 +9395,7 @@ var init_graph_model = __esm({
|
|
|
9228
9395
|
GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
|
|
9229
9396
|
GRAPH_FLAGS = Object.freeze([
|
|
9230
9397
|
"no-skill",
|
|
9231
|
-
// config edge target absent
|
|
9398
|
+
// config edge target absent in EVERY checkable applicable tier (readFlowGate's precise token, now with the full ladder behind it)
|
|
9232
9399
|
"charset-invalid",
|
|
9233
9400
|
// run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
|
|
9234
9401
|
"orphan",
|
|
@@ -9242,6 +9409,26 @@ var init_graph_model = __esm({
|
|
|
9242
9409
|
"pr-spend-loop-risk"
|
|
9243
9410
|
// pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
|
|
9244
9411
|
]);
|
|
9412
|
+
GRAPH_NODE_KINDS = Object.freeze([
|
|
9413
|
+
"trigger",
|
|
9414
|
+
"skill",
|
|
9415
|
+
// committed at HEAD in an enumerated folder
|
|
9416
|
+
"skill-missing",
|
|
9417
|
+
// absent in every checkable applicable tier: the one red, dangling state
|
|
9418
|
+
"skill-unverified",
|
|
9419
|
+
// the folder itself was never readable (forge/remote): a read that never happened
|
|
9420
|
+
"skill-not-at-head",
|
|
9421
|
+
// absent at HEAD, but some lower tier was not checkable from this session
|
|
9422
|
+
"injected",
|
|
9423
|
+
// run.skillsDir working-tree skill: trigger-reachable, never AI-reachable
|
|
9424
|
+
"overlay",
|
|
9425
|
+
// deployment overlay skills/: trigger-reachable, never AI-reachable
|
|
9426
|
+
"staged"
|
|
9427
|
+
// a staged pi package's skill: trigger-reachable, never AI-reachable
|
|
9428
|
+
]);
|
|
9429
|
+
TIER_INJECTED = "injected run.skillsDir";
|
|
9430
|
+
TIER_OVERLAY = "overlay skills/";
|
|
9431
|
+
TIER_STAGED = "staged packages";
|
|
9245
9432
|
}
|
|
9246
9433
|
});
|
|
9247
9434
|
|
|
@@ -9889,6 +10076,11 @@ async function enqueueDispatchRun({
|
|
|
9889
10076
|
if (typeof flow !== "string" || flow.trim() === "") {
|
|
9890
10077
|
return { refused: "no flow \u2014 a flow is required to trigger a run" };
|
|
9891
10078
|
}
|
|
10079
|
+
if (flow.trim().startsWith("/")) {
|
|
10080
|
+
return {
|
|
10081
|
+
refused: `flow '${flow}' names a command, not a flow \u2014 commands are not AI-triggerable; a registered extension command runs only from a reviewed triggers.json entry (run.command)`
|
|
10082
|
+
};
|
|
10083
|
+
}
|
|
9892
10084
|
const { valkeyUrl, dispatchRunRoots, dispatchRunPerHour } = resolvePaths(env);
|
|
9893
10085
|
if (aiInvoked && !folderUnderRoots(folder, dispatchRunRoots)) {
|
|
9894
10086
|
return { refused: "folder not under PI_DISPATCH_RUN_ROOTS" };
|
|
@@ -10098,8 +10290,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
|
|
|
10098
10290
|
}
|
|
10099
10291
|
function withTimeout(promise, ms, fallback) {
|
|
10100
10292
|
let timer;
|
|
10101
|
-
const timeout = new Promise((
|
|
10102
|
-
timer = setTimeout(() =>
|
|
10293
|
+
const timeout = new Promise((resolve2) => {
|
|
10294
|
+
timer = setTimeout(() => resolve2(fallback), ms);
|
|
10103
10295
|
if (typeof timer?.unref === "function") timer.unref();
|
|
10104
10296
|
});
|
|
10105
10297
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
@@ -10193,6 +10385,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10193
10385
|
if (on === null || typeof on !== "object") return null;
|
|
10194
10386
|
const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
|
|
10195
10387
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
10388
|
+
const command = typeof run.command === "string" ? run.command : null;
|
|
10196
10389
|
const packages = run.packages !== false;
|
|
10197
10390
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
10198
10391
|
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
|
|
@@ -10208,6 +10401,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10208
10401
|
pattern: typeof on.pattern === "string" ? on.pattern : null,
|
|
10209
10402
|
folder: typeof run.folder === "string" ? run.folder : null,
|
|
10210
10403
|
flow,
|
|
10404
|
+
command,
|
|
10211
10405
|
// Optional per-cron model override (passthrough into job.data); null when the entry resolves the
|
|
10212
10406
|
// deployment default. Surfaced so the drill-in shows which schedules pin their own model.
|
|
10213
10407
|
model: typeof run.model === "string" ? run.model : null,
|
|
@@ -10218,9 +10412,9 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10218
10412
|
resume
|
|
10219
10413
|
};
|
|
10220
10414
|
case "label":
|
|
10221
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10415
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10222
10416
|
case "comment":
|
|
10223
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10417
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10224
10418
|
case "pull_request":
|
|
10225
10419
|
return {
|
|
10226
10420
|
type: "pull_request",
|
|
@@ -10229,6 +10423,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10229
10423
|
all: normalizeSelector(on.all),
|
|
10230
10424
|
none: normalizeSelector(on.none),
|
|
10231
10425
|
flow,
|
|
10426
|
+
command,
|
|
10232
10427
|
packages,
|
|
10233
10428
|
image,
|
|
10234
10429
|
skillsDir,
|
|
@@ -10476,8 +10671,69 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
|
|
|
10476
10671
|
}
|
|
10477
10672
|
return { skills, truncated, unreachable: null };
|
|
10478
10673
|
}
|
|
10479
|
-
function
|
|
10480
|
-
|
|
10674
|
+
function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
|
|
10675
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
|
|
10676
|
+
const dir = join2(globalPiDir, "skills");
|
|
10677
|
+
let names;
|
|
10678
|
+
try {
|
|
10679
|
+
names = fs.readdirSync(dir);
|
|
10680
|
+
} catch (err) {
|
|
10681
|
+
if (err?.code === "ENOENT") return { skills: [], truncated: false, unreachable: null };
|
|
10682
|
+
return { skills: [], truncated: false, unreachable: "unreadable" };
|
|
10683
|
+
}
|
|
10684
|
+
const valid = names.filter((n) => SKILL_NAME_RE.test(n)).sort();
|
|
10685
|
+
const truncated = valid.length > GRAPH_LIMITS.maxSkillsPerFolder;
|
|
10686
|
+
const skills = [];
|
|
10687
|
+
for (const name of valid.slice(0, GRAPH_LIMITS.maxSkillsPerFolder)) {
|
|
10688
|
+
let present = false;
|
|
10689
|
+
try {
|
|
10690
|
+
present = fs.existsSync(join2(dir, name, "SKILL.md"));
|
|
10691
|
+
} catch {
|
|
10692
|
+
present = false;
|
|
10693
|
+
}
|
|
10694
|
+
if (present) skills.push({ name });
|
|
10695
|
+
}
|
|
10696
|
+
return { skills, truncated, unreachable: null };
|
|
10697
|
+
}
|
|
10698
|
+
function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
|
|
10699
|
+
const empty = { skills: [], unenumerable: [], truncated: false };
|
|
10700
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
10701
|
+
let result;
|
|
10702
|
+
try {
|
|
10703
|
+
result = readStaged({
|
|
10704
|
+
globalPiDir,
|
|
10705
|
+
readFile: (path) => fs.readFileSync(path, "utf8"),
|
|
10706
|
+
fileExists: (path) => fs.existsSync(path),
|
|
10707
|
+
readDir: (path, opts) => fs.readdirSync(path, opts)
|
|
10708
|
+
});
|
|
10709
|
+
} catch {
|
|
10710
|
+
return empty;
|
|
10711
|
+
}
|
|
10712
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10713
|
+
const skills = [];
|
|
10714
|
+
for (const s of Array.isArray(result?.skills) ? result.skills : []) {
|
|
10715
|
+
if (typeof s?.name !== "string" || !SKILL_NAME_RE.test(s.name)) continue;
|
|
10716
|
+
if (typeof s.dir !== "string" || s.dir === "") continue;
|
|
10717
|
+
const key = `${s.dir} ${s.name}`;
|
|
10718
|
+
if (seen.has(key)) continue;
|
|
10719
|
+
seen.add(key);
|
|
10720
|
+
skills.push({ name: s.name, package: typeof s.package === "string" && s.package !== "" ? s.package : s.dir, dir: s.dir });
|
|
10721
|
+
}
|
|
10722
|
+
return {
|
|
10723
|
+
skills: skills.slice(0, GRAPH_LIMITS.maxStagedSkills),
|
|
10724
|
+
unenumerable: (Array.isArray(result?.unenumerable) ? result.unenumerable : []).filter((p) => typeof p === "string").sort(),
|
|
10725
|
+
truncated: skills.length > GRAPH_LIMITS.maxStagedSkills
|
|
10726
|
+
};
|
|
10727
|
+
}
|
|
10728
|
+
function collectGraphInputs({
|
|
10729
|
+
triggers,
|
|
10730
|
+
globalPiDir = null,
|
|
10731
|
+
readFolder = readFolderSkills,
|
|
10732
|
+
readInjected = readInjectedSkills,
|
|
10733
|
+
readOverlay: readOverlay2 = readOverlaySkills,
|
|
10734
|
+
readStaged = readStagedSkillsList
|
|
10735
|
+
} = {}) {
|
|
10736
|
+
const out = { folderSkills: {}, injectedSkills: {}, overlaySkills: null, stagedSkills: null, foldersTruncated: false };
|
|
10481
10737
|
if (!Array.isArray(triggers)) return out;
|
|
10482
10738
|
const folders = [];
|
|
10483
10739
|
const injectedDirs = [];
|
|
@@ -10492,6 +10748,10 @@ function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjec
|
|
|
10492
10748
|
for (const dir of injectedDirs.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
|
|
10493
10749
|
out.injectedSkills[dir] = readInjected({ skillsDir: dir });
|
|
10494
10750
|
}
|
|
10751
|
+
if (typeof globalPiDir === "string" && globalPiDir !== "") {
|
|
10752
|
+
out.overlaySkills = readOverlay2({ globalPiDir });
|
|
10753
|
+
out.stagedSkills = readStaged({ globalPiDir });
|
|
10754
|
+
}
|
|
10495
10755
|
return out;
|
|
10496
10756
|
}
|
|
10497
10757
|
function forgeRepoTargets({ records } = {}) {
|
|
@@ -10582,8 +10842,10 @@ var init_read_model = __esm({
|
|
|
10582
10842
|
// distinct observed chain edges kept; beyond this the graph says "truncated"
|
|
10583
10843
|
windowDays: 30,
|
|
10584
10844
|
// run-record window the graph folds; matches the default PI_LOG_RETENTION_DAYS
|
|
10585
|
-
maxReposListed: 5
|
|
10845
|
+
maxReposListed: 5,
|
|
10586
10846
|
// repos named per forge group, from record targets; a scope label, not an inventory
|
|
10847
|
+
maxStagedSkills: 64
|
|
10848
|
+
// staged-package skills kept for display; one stage read per graph build
|
|
10587
10849
|
});
|
|
10588
10850
|
GRAPH_LS_TREE_MAX_BYTES = 1 << 20;
|
|
10589
10851
|
}
|
|
@@ -10637,7 +10899,11 @@ function normalizeModel(model) {
|
|
|
10637
10899
|
// dropped (issue #175): three surfaces of one model must not disagree about what was refused
|
|
10638
10900
|
// or unreadable. Sorted and clipped like every other array on this page.
|
|
10639
10901
|
chainRefusals: (m.meta?.chainRefusals && typeof m.meta.chainRefusals === "object" ? Object.entries(m.meta.chainRefusals) : []).flatMap(([scope, count]) => typeof scope === "string" && intOr(count, 0) > 0 ? [{ scope: clip(scope, 80), count: intOr(count, 0) }] : []).sort((a, b) => cmpStr(a.scope, b.scope)),
|
|
10640
|
-
injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8)
|
|
10902
|
+
injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8),
|
|
10903
|
+
// The two tier-honesty counters (issue #188), the injectedUnreachable rule applied to the
|
|
10904
|
+
// deployment-wide tiers: a tier the ladder silently skipped would read as "checked".
|
|
10905
|
+
overlayUnreachable: m.meta?.overlayUnreachable === true,
|
|
10906
|
+
stagedUnenumerable: (Array.isArray(m.meta?.stagedUnenumerable) ? m.meta.stagedUnenumerable : []).filter((d) => typeof d === "string").map((d) => clip(d, 80)).sort().slice(0, 8)
|
|
10641
10907
|
};
|
|
10642
10908
|
const folders = [];
|
|
10643
10909
|
for (const f of Array.isArray(m.folders) ? m.folders : []) {
|
|
@@ -10671,6 +10937,13 @@ function normalizeModel(model) {
|
|
|
10671
10937
|
onType: typeof n.onType === "string" ? n.onType : null,
|
|
10672
10938
|
pattern: typeof n.pattern === "string" ? n.pattern : null,
|
|
10673
10939
|
flow: typeof n.flow === "string" ? n.flow : null,
|
|
10940
|
+
// A command trigger's slash command (issue #189's node fact, #188's rendering): the tip is the
|
|
10941
|
+
// detail surface, so the full command rides, clipped like every other string on this page.
|
|
10942
|
+
command: typeof n.command === "string" ? clip(n.command, 80) : null,
|
|
10943
|
+
// The staged tier node's owning package, and the softened state's evidence: which tiers this
|
|
10944
|
+
// session could not check. Both clipped/sorted/capped so a hostile model cannot inflate a tip.
|
|
10945
|
+
package: typeof n.package === "string" ? clip(n.package, 80) : null,
|
|
10946
|
+
tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
|
|
10674
10947
|
replicas: intOr(n.replicas, null),
|
|
10675
10948
|
folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
|
|
10676
10949
|
runs: intOr(n.runs, 0),
|
|
@@ -10751,6 +11024,8 @@ function layoutNormalized(norm) {
|
|
|
10751
11024
|
placedByOrig.set(n.id, p);
|
|
10752
11025
|
let g = n.folderKey === null ? null : groupIndexByKey.get(n.folderKey) ?? null;
|
|
10753
11026
|
if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
|
|
11027
|
+
if (!g && n.kind === "overlay") g = groupIndexByKey.get("~overlay") ?? addGroup("~overlay", "overlay skills (global pi dir)", "local", null, null);
|
|
11028
|
+
if (!g && n.kind === "staged") g = groupIndexByKey.get("~staged") ?? addGroup("~staged", "staged package skills", "local", null, null);
|
|
10754
11029
|
if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
|
|
10755
11030
|
g.members.push(p);
|
|
10756
11031
|
p.groupId = g.id;
|
|
@@ -11080,13 +11355,23 @@ function buildTip(n, flags, groupLabel, nowMs) {
|
|
|
11080
11355
|
if (n.kind === "trigger") {
|
|
11081
11356
|
lines.push(`trigger \xB7 ${n.onType ?? "?"} \xB7 ${n.label ?? ""}`.trim());
|
|
11082
11357
|
if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
|
|
11358
|
+
else if (n.command !== null) lines.push(`command: /${n.command}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
|
|
11083
11359
|
} else if (n.kind === "injected") {
|
|
11084
11360
|
lines.push(`injected skill \xB7 ${n.name ?? "?"}`);
|
|
11085
11361
|
lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
|
|
11362
|
+
} else if (n.kind === "overlay") {
|
|
11363
|
+
lines.push(`overlay skill \xB7 ${n.name ?? "?"}`);
|
|
11364
|
+
lines.push("deployment overlay skills/, trigger-reachable, never AI-reachable");
|
|
11365
|
+
} else if (n.kind === "staged") {
|
|
11366
|
+
lines.push(`staged skill \xB7 ${n.name ?? "?"}${n.package !== null ? ` \xB7 package ${n.package}` : ""}`);
|
|
11367
|
+
lines.push("staged pi package, trigger-reachable, never AI-reachable");
|
|
11086
11368
|
} else if (n.kind === "skill-missing") {
|
|
11087
11369
|
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 missing at HEAD`);
|
|
11088
11370
|
} else if (n.kind === "skill-unverified") {
|
|
11089
11371
|
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 unverified (repo not readable from this host)`);
|
|
11372
|
+
} else if (n.kind === "skill-not-at-head") {
|
|
11373
|
+
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 not committed at HEAD`);
|
|
11374
|
+
if (n.tiersUnknown.length > 0) lines.push(`not checkable from this session: ${n.tiersUnknown.join(", ")}`);
|
|
11090
11375
|
} else {
|
|
11091
11376
|
lines.push(`skill \xB7 ${n.name ?? "?"}${n.isSub ? " \xB7 sub-skill (never a flow)" : ""}`);
|
|
11092
11377
|
}
|
|
@@ -11133,6 +11418,7 @@ function nodeState(n, flagNames) {
|
|
|
11133
11418
|
return { stroke: DANGER, dash: "10,4", faded: false };
|
|
11134
11419
|
}
|
|
11135
11420
|
if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
|
|
11421
|
+
if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
|
|
11136
11422
|
if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
|
|
11137
11423
|
return { stroke: CHIP_STROKE, dash: null, faded: false };
|
|
11138
11424
|
}
|
|
@@ -11248,7 +11534,8 @@ function legendHtml(norm) {
|
|
|
11248
11534
|
row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
|
|
11249
11535
|
row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
|
|
11250
11536
|
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${CHIP_STROKE}" stroke-dasharray="8,3"/>`), "orphan: no trigger, no ai-trigger, no mention");
|
|
11251
|
-
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling:
|
|
11537
|
+
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: absent in every checkable tier or name invalid");
|
|
11538
|
+
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_AMBER}" stroke-dasharray="10,4"/>`), "not at HEAD: some skill tiers not checkable from this session");
|
|
11252
11539
|
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_DIM}" stroke-dasharray="10,4"/>`), "unverified: repo not readable from this host");
|
|
11253
11540
|
rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
|
|
11254
11541
|
const honesty = [];
|
|
@@ -11260,6 +11547,8 @@ function legendHtml(norm) {
|
|
|
11260
11547
|
const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
|
|
11261
11548
|
if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
|
|
11262
11549
|
if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
|
|
11550
|
+
if (norm.meta.overlayUnreachable) honesty.push("overlay skills dir unreadable (global pi dir)");
|
|
11551
|
+
if (norm.meta.stagedUnenumerable.length > 0) honesty.push(`staged packages not enumerable (manifest patterns): ${norm.meta.stagedUnenumerable.join(", ")}`);
|
|
11263
11552
|
for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
|
|
11264
11553
|
return `<div id="legend">${rows.join("")}</div>`;
|
|
11265
11554
|
}
|
|
@@ -11406,7 +11695,10 @@ var init_graph_html = __esm({
|
|
|
11406
11695
|
skill: "\u0192",
|
|
11407
11696
|
"skill-missing": "!",
|
|
11408
11697
|
"skill-unverified": "?",
|
|
11409
|
-
|
|
11698
|
+
"skill-not-at-head": "\u22EF",
|
|
11699
|
+
injected: "+",
|
|
11700
|
+
overlay: "\u25CE",
|
|
11701
|
+
staged: "\u25A3"
|
|
11410
11702
|
});
|
|
11411
11703
|
ORANGE_FLAGS = /* @__PURE__ */ new Set(["unread", "injected-ai-trigger", "pr-spend-loop-risk"]);
|
|
11412
11704
|
DANGLING_FLAGS = /* @__PURE__ */ new Set(["no-skill", "charset-invalid"]);
|
|
@@ -38371,7 +38663,7 @@ var init_all = __esm({
|
|
|
38371
38663
|
|
|
38372
38664
|
// ../worker/src/pricing.mjs
|
|
38373
38665
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
38374
|
-
import { dirname as
|
|
38666
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
38375
38667
|
import { fileURLToPath } from "node:url";
|
|
38376
38668
|
function listPricedModels() {
|
|
38377
38669
|
const out = [];
|
|
@@ -38400,7 +38692,7 @@ function readTextFromDisk(path) {
|
|
|
38400
38692
|
}
|
|
38401
38693
|
function readPiAiVersion(readText) {
|
|
38402
38694
|
try {
|
|
38403
|
-
let dir =
|
|
38695
|
+
let dir = dirname3(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
|
|
38404
38696
|
for (let hops = 0; hops < 8; hops++) {
|
|
38405
38697
|
try {
|
|
38406
38698
|
const parsed = JSON.parse(readText(join4(dir, "package.json")));
|
|
@@ -38410,7 +38702,7 @@ function readPiAiVersion(readText) {
|
|
|
38410
38702
|
}
|
|
38411
38703
|
} catch {
|
|
38412
38704
|
}
|
|
38413
|
-
const parent =
|
|
38705
|
+
const parent = dirname3(dir);
|
|
38414
38706
|
if (parent === dir) break;
|
|
38415
38707
|
dir = parent;
|
|
38416
38708
|
}
|
|
@@ -38455,7 +38747,7 @@ var init_pricing = __esm({
|
|
|
38455
38747
|
});
|
|
38456
38748
|
|
|
38457
38749
|
// ../worker/src/sandbox-store.mjs
|
|
38458
|
-
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
38750
|
+
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
38459
38751
|
import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
|
|
38460
38752
|
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
38461
38753
|
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
@@ -38472,7 +38764,7 @@ var init_sandbox_store = __esm({
|
|
|
38472
38764
|
"../worker/src/sandbox-store.mjs"() {
|
|
38473
38765
|
init_run_history();
|
|
38474
38766
|
SANDBOX_MANIFEST = "manifest.json";
|
|
38475
|
-
defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync, renameSync, rmSync, writeFileSync };
|
|
38767
|
+
defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync: readdirSync2, renameSync, rmSync, writeFileSync };
|
|
38476
38768
|
}
|
|
38477
38769
|
});
|
|
38478
38770
|
|
|
@@ -38483,7 +38775,7 @@ import { promisify as promisify4 } from "node:util";
|
|
|
38483
38775
|
function sandboxContainerName(jobId) {
|
|
38484
38776
|
return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
|
|
38485
38777
|
}
|
|
38486
|
-
function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
|
|
38778
|
+
function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {} }) {
|
|
38487
38779
|
return buildDockerRunArgs({
|
|
38488
38780
|
image,
|
|
38489
38781
|
name,
|
|
@@ -38492,9 +38784,20 @@ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], ter
|
|
|
38492
38784
|
// The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
|
|
38493
38785
|
// forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
|
|
38494
38786
|
// disabled idle timeout emits nothing rather than an empty string.
|
|
38787
|
+
// A sandbox joins the SAME kind of network a job did, by the same builder, so the boundary cannot
|
|
38788
|
+
// land on job containers and miss this one. Leaving sandboxes on the default bridge was the tempting
|
|
38789
|
+
// alternative and it is the wrong one: it reads as a convenience (install a missing dependency while
|
|
38790
|
+
// debugging) and it is a WIDER reach than the run the sandbox exists to reproduce. A shell that can
|
|
38791
|
+
// go where the run could not is not reproducing the run. Nothing an operator wants is lost, because
|
|
38792
|
+
// the forge and the registry are on the allowlist a job needed anyway.
|
|
38793
|
+
network,
|
|
38495
38794
|
env: {
|
|
38496
38795
|
TERM: term || void 0,
|
|
38497
|
-
TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
|
|
38796
|
+
TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0,
|
|
38797
|
+
// Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
|
|
38798
|
+
// buildContainerEnv is still not reused here. The env is two variables about the terminal and,
|
|
38799
|
+
// when a policy is armed, three about the network.
|
|
38800
|
+
...proxyEnv
|
|
38498
38801
|
},
|
|
38499
38802
|
// Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
|
|
38500
38803
|
// the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
|
|
@@ -38518,10 +38821,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
|
|
|
38518
38821
|
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
38519
38822
|
}
|
|
38520
38823
|
function launchSandbox({ args, spawnFn = spawn }) {
|
|
38521
|
-
return new Promise((
|
|
38824
|
+
return new Promise((resolve2) => {
|
|
38522
38825
|
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
38523
|
-
child.on("error", (err) =>
|
|
38524
|
-
child.on("close", (code) =>
|
|
38826
|
+
child.on("error", (err) => resolve2({ code: null, error: err }));
|
|
38827
|
+
child.on("close", (code) => resolve2({ code }));
|
|
38525
38828
|
});
|
|
38526
38829
|
}
|
|
38527
38830
|
var exec3, SANDBOX_NAME_PREFIX;
|
|
@@ -38606,7 +38909,7 @@ function renderTriggers({ schedulers, triggers } = {}) {
|
|
|
38606
38909
|
return out.join("\n");
|
|
38607
38910
|
}
|
|
38608
38911
|
function triggerLine(t) {
|
|
38609
|
-
const flow = t?.flow ?? "-";
|
|
38912
|
+
const flow = t?.flow ?? commandSlashLabel(t) ?? "-";
|
|
38610
38913
|
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
38611
38914
|
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
38612
38915
|
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
@@ -38630,6 +38933,9 @@ function triggerLine(t) {
|
|
|
38630
38933
|
return "(unknown trigger)";
|
|
38631
38934
|
}
|
|
38632
38935
|
}
|
|
38936
|
+
function commandSlashLabel(t) {
|
|
38937
|
+
return typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : null;
|
|
38938
|
+
}
|
|
38633
38939
|
function ruleClauses(rule) {
|
|
38634
38940
|
const clauses = [];
|
|
38635
38941
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -39574,7 +39880,7 @@ function matchColored(t, styler) {
|
|
|
39574
39880
|
}
|
|
39575
39881
|
function targetColored(t, styler) {
|
|
39576
39882
|
const arrow = styler.fg("dim", "\u2192");
|
|
39577
|
-
const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
|
|
39883
|
+
const flow = styler.bold(styler.fg("text", t?.flow ?? commandSlashLabel(t) ?? "-"));
|
|
39578
39884
|
if (t?.type === "cron") {
|
|
39579
39885
|
const base = t.folder ? String(t.folder).split(/[/\\]/).filter(Boolean).pop() ?? "" : "";
|
|
39580
39886
|
const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
|
|
@@ -39678,7 +39984,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
39678
39984
|
const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
|
|
39679
39985
|
const blank = () => out.push(styler.cell("", inner));
|
|
39680
39986
|
const section = (label) => out.push(styler.divider(label, null, inner));
|
|
39681
|
-
let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? "-"}`));
|
|
39987
|
+
let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? commandSlashLabel(t) ?? "-"}`));
|
|
39682
39988
|
if (t.type === "cron" && sched) {
|
|
39683
39989
|
const healthy = !sched.overdueMs;
|
|
39684
39990
|
header += " " + (healthy ? styler.fg("success", "\u2714 healthy") : styler.fg("warning", `\u26A0 overdue ${formatDuration(sched.overdueMs)}`));
|
|
@@ -39714,6 +40020,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
39714
40020
|
out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
|
|
39715
40021
|
out.push(kv("model", "deployment default", "dim"));
|
|
39716
40022
|
}
|
|
40023
|
+
if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
|
|
39717
40024
|
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
39718
40025
|
out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
|
|
39719
40026
|
out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
|
|
@@ -39942,7 +40249,11 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
|
|
|
39942
40249
|
out.push(kv("chain", chainBits.join(" \xB7 ")));
|
|
39943
40250
|
if (r.replica > 0) {
|
|
39944
40251
|
const sibs = (Array.isArray(allRuns) ? allRuns : []).filter(
|
|
39945
|
-
|
|
40252
|
+
// `kind` is load-bearing since #187, not decoration: `target` is repo + separator + number, and
|
|
40253
|
+
// targetFor renders github and forgejo with the SAME `#`, so a github `o/r#5` and a forgejo `o/r#5`
|
|
40254
|
+
// are one string. While only github could replicate, no two replica runs could collide; now they can,
|
|
40255
|
+
// and naming a stranger's job as your sibling is worse than naming none.
|
|
40256
|
+
(x) => x?.replica > 0 && x.replica !== r.replica && x.kind === r.kind && x.target === r.target && x.flow === r.flow
|
|
39946
40257
|
);
|
|
39947
40258
|
const repBits = [`r${r.replica}/${r.replicas ?? "?"}`];
|
|
39948
40259
|
repBits.push(sibs.length > 0 ? `sibling ${sibs.map((s) => `r${s.replica} ${s.jobId}`).join(", ")}` : "no sibling in this window");
|
|
@@ -40096,16 +40407,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
|
|
|
40096
40407
|
${title}
|
|
40097
40408
|
|
|
40098
40409
|
`);
|
|
40099
|
-
outcome = await new Promise((
|
|
40410
|
+
outcome = await new Promise((resolve2) => {
|
|
40100
40411
|
let child;
|
|
40101
40412
|
try {
|
|
40102
40413
|
child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
|
|
40103
40414
|
} catch (err) {
|
|
40104
|
-
|
|
40415
|
+
resolve2({ code: null, error: err });
|
|
40105
40416
|
return;
|
|
40106
40417
|
}
|
|
40107
|
-
child.on("error", (err) =>
|
|
40108
|
-
child.on("close", (code) =>
|
|
40418
|
+
child.on("error", (err) => resolve2({ code: null, error: err }));
|
|
40419
|
+
child.on("close", (code) => resolve2({ code }));
|
|
40109
40420
|
});
|
|
40110
40421
|
} catch (err) {
|
|
40111
40422
|
outcome = { code: null, error: err };
|
|
@@ -40556,8 +40867,8 @@ var init_setup_wizard = __esm({
|
|
|
40556
40867
|
init_deployment_pointer();
|
|
40557
40868
|
init_read_model();
|
|
40558
40869
|
init_index();
|
|
40559
|
-
RUNTIME_VERSION = "
|
|
40560
|
-
RECEIVER_VERSION = "
|
|
40870
|
+
RUNTIME_VERSION = "1.1.0";
|
|
40871
|
+
RECEIVER_VERSION = "1.1.0";
|
|
40561
40872
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
40562
40873
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
40563
40874
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
@@ -40699,7 +41010,7 @@ function registerTools(pi) {
|
|
|
40699
41010
|
pi.registerTool({
|
|
40700
41011
|
name: "dispatch_run",
|
|
40701
41012
|
label: "pi-dispatch run",
|
|
40702
|
-
description: "Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md (at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree \u2014 no force option. Rate-limited per hour.",
|
|
41013
|
+
description: "Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md (at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree \u2014 no force option. Rate-limited per hour. Flows only: a registered extension command (a run.command trigger) is never AI-triggerable and a /name flow is refused.",
|
|
40703
41014
|
executionMode: "sequential",
|
|
40704
41015
|
parameters: typebox_exports.Object({ folder: typebox_exports.String(), flow: typebox_exports.String(), task: typebox_exports.String() }),
|
|
40705
41016
|
async execute(_toolCallId, params) {
|
|
@@ -40762,12 +41073,16 @@ function registerTools(pi) {
|
|
|
40762
41073
|
pi.registerTool({
|
|
40763
41074
|
name: "dispatch_trigger_add",
|
|
40764
41075
|
label: "pi-dispatch add trigger",
|
|
40765
|
-
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.",
|
|
41076
|
+
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 | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. 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.",
|
|
40766
41077
|
executionMode: "sequential",
|
|
40767
41078
|
parameters: typebox_exports.Object({
|
|
40768
41079
|
kind: typebox_exports.String(),
|
|
40769
41080
|
flow: typebox_exports.String(),
|
|
40770
41081
|
forge: typebox_exports.Optional(typebox_exports.String()),
|
|
41082
|
+
// buildTriggerEntry has always READ `f.repository`, and the schema has always stripped it -- so an
|
|
41083
|
+
// azure label/comment trigger was unauthorable by the model, refused at the write for want of a field
|
|
41084
|
+
// it had no way to send. Surfaced by #187 widening what the description above must say about forges.
|
|
41085
|
+
repository: typebox_exports.Optional(typebox_exports.String()),
|
|
40771
41086
|
id: typebox_exports.Optional(typebox_exports.String()),
|
|
40772
41087
|
pattern: typebox_exports.Optional(typebox_exports.String()),
|
|
40773
41088
|
folder: typebox_exports.Optional(typebox_exports.String()),
|
|
@@ -41186,7 +41501,7 @@ async function assembleGraph(paths) {
|
|
|
41186
41501
|
return buildGraphModel({
|
|
41187
41502
|
triggers,
|
|
41188
41503
|
schedulers: Array.isArray(schedulers) ? schedulers : [],
|
|
41189
|
-
...collectGraphInputs({ triggers: triggerList2 }),
|
|
41504
|
+
...collectGraphInputs({ triggers: triggerList2, globalPiDir: paths.globalPiDir }),
|
|
41190
41505
|
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
|
|
41191
41506
|
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
|
|
41192
41507
|
chainEdges: observedChainEdges({ records: recs }),
|
|
@@ -41395,7 +41710,7 @@ could not start docker: ${error.message}
|
|
|
41395
41710
|
}
|
|
41396
41711
|
}
|
|
41397
41712
|
function pauseForMessage() {
|
|
41398
|
-
return new Promise((
|
|
41713
|
+
return new Promise((resolve2) => setTimeout(resolve2, 2500));
|
|
41399
41714
|
}
|
|
41400
41715
|
function splitWords(s) {
|
|
41401
41716
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -122,14 +122,16 @@ job time; and `pi-dispatch doctor` lists every image their triggers name and fla
|
|
|
122
122
|
|
|
123
123
|
## The forge a trigger listens to — `run.kind`
|
|
124
124
|
|
|
125
|
-
A webhook trigger names its forge: `"kind"
|
|
126
|
-
trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
|
|
125
|
+
A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else
|
|
126
|
+
about the trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
|
|
127
|
+
`image`, `replicas`.
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
Three things are NOT the same, and all of them refuse at load rather than misbehaving quietly:
|
|
129
130
|
|
|
130
131
|
- **`pull_request` actions are the forge's own words.** GitHub takes
|
|
131
132
|
`labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
|
|
132
|
-
`open | update | reopen | approved
|
|
133
|
+
`open | update | reopen | approved`; Forgejo takes
|
|
134
|
+
`label_updated | opened | synchronized | reopened`; Azure takes `created | updated`. A word
|
|
133
135
|
from the wrong forge is refused when the file is written. It would not break anything otherwise — it
|
|
134
136
|
would simply never match an event, and the trigger would look configured while doing nothing.
|
|
135
137
|
`review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
|
|
@@ -138,6 +140,29 @@ Two things are NOT the same, and both refuse at load rather than misbehaving qui
|
|
|
138
140
|
load, because a narrowing that cannot apply reads as one that does.
|
|
139
141
|
- **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
|
|
140
142
|
are fine.
|
|
143
|
+
- **Azure names no repository.** A work item belongs to a project, so an azure `label` or `comment` trigger
|
|
144
|
+
MUST set `run.repository` (the repo within the project to clone) and every other forge's must not. An
|
|
145
|
+
azure `pull_request` trigger may not carry a label predicate at all: Azure tags work items, never pull
|
|
146
|
+
requests, so `any`/`all`/`none` could never match and a rule that loads clean and never fires reads as a
|
|
147
|
+
broken harness.
|
|
148
|
+
|
|
149
|
+
## Racing two agents on one event — `run.replicas`
|
|
150
|
+
|
|
151
|
+
`"replicas": 2` on a `label`, `comment` or `pull_request` trigger turns one delivery into **two independent
|
|
152
|
+
paid jobs**: two containers, two branches (`pi/issue-7-r1` and `-r2`), two review requests, one human
|
|
153
|
+
picking. It works on every forge. Absent, nothing changes.
|
|
154
|
+
|
|
155
|
+
Three things to say when an operator asks about it:
|
|
156
|
+
|
|
157
|
+
- **It multiplies spend, and the caps are the only ceiling.** Each replica reserves its own budget slot
|
|
158
|
+
before its own tokens, so a `replicas: 2` trigger firing ten times a day consumes twenty slots of the
|
|
159
|
+
daily cap, not ten. Nothing is discounted; that is the feature, not an oversight.
|
|
160
|
+
- **You cannot set it from here.** There is no `replicas` parameter on `dispatch_trigger_add` or
|
|
161
|
+
`_edit`, and no panel key. It is a reviewed edit to `triggers.json`, deliberately: a spend multiplier is
|
|
162
|
+
plainly a capability a model should not gain. Say so rather than looking for a way around it.
|
|
163
|
+
- **It refuses on cron, and beside `resume`.** A local job's `/workspace` IS the operator's folder, so two
|
|
164
|
+
replicas would edit one working tree with no gate and no undo. And a resumed run continues one lineage
|
|
165
|
+
where replicas exist to fork it.
|
|
141
166
|
|
|
142
167
|
`dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
|
|
143
168
|
one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,
|