@edgehero/pi-dispatch-admin 0.8.0 → 1.0.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 +6 -6
- package/dist/index.mjs +344 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
@@ -7927,13 +7927,14 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7927
7927
|
if (fieldCount !== 5 && fieldCount !== 6) {
|
|
7928
7928
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
7929
7929
|
}
|
|
7930
|
+
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
7930
7931
|
if (!isNonEmptyString2(run.folder)) {
|
|
7931
7932
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
7932
7933
|
}
|
|
7933
|
-
if (!isNonEmptyString2(run.flow)) {
|
|
7934
|
-
throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
|
|
7934
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
7935
|
+
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
7935
7936
|
}
|
|
7936
|
-
if (!isNonEmptyString2(run.task)) {
|
|
7937
|
+
if (command === void 0 && !isNonEmptyString2(run.task)) {
|
|
7937
7938
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
7938
7939
|
}
|
|
7939
7940
|
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
@@ -7947,7 +7948,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
7947
7948
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
7948
7949
|
return {
|
|
7949
7950
|
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 } }
|
|
7951
|
+
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
7952
|
};
|
|
7952
7953
|
}
|
|
7953
7954
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -8006,6 +8007,35 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
|
8006
8007
|
}
|
|
8007
8008
|
return text;
|
|
8008
8009
|
}
|
|
8010
|
+
function validateCommand(run, at, path, { onType }) {
|
|
8011
|
+
const raw = run.command;
|
|
8012
|
+
if (run.flow !== void 0 && raw !== void 0) {
|
|
8013
|
+
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}`);
|
|
8014
|
+
}
|
|
8015
|
+
if (raw === void 0) return void 0;
|
|
8016
|
+
if (!isNonEmptyString2(raw)) {
|
|
8017
|
+
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
8018
|
+
}
|
|
8019
|
+
if (raw !== raw.trim()) {
|
|
8020
|
+
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}`);
|
|
8021
|
+
}
|
|
8022
|
+
if (raw.startsWith("/")) {
|
|
8023
|
+
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}`);
|
|
8024
|
+
}
|
|
8025
|
+
if (/[\u0000-\u001F\u007F]/.test(raw)) {
|
|
8026
|
+
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}`);
|
|
8027
|
+
}
|
|
8028
|
+
if (onType === "cron" && run.task !== void 0) {
|
|
8029
|
+
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}`);
|
|
8030
|
+
}
|
|
8031
|
+
if (onType !== "cron" && run.instructions !== void 0) {
|
|
8032
|
+
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}`);
|
|
8033
|
+
}
|
|
8034
|
+
if (run.resume === true) {
|
|
8035
|
+
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}`);
|
|
8036
|
+
}
|
|
8037
|
+
return raw;
|
|
8038
|
+
}
|
|
8009
8039
|
function validatePredicate(on, index, path, requirePositive) {
|
|
8010
8040
|
const at = `trigger at index ${index}`;
|
|
8011
8041
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -8055,8 +8085,9 @@ function validateReplicas(run, at, path) {
|
|
|
8055
8085
|
function normalizeLabel(on, run, index, path) {
|
|
8056
8086
|
const at = `trigger at index ${index}`;
|
|
8057
8087
|
const predicate = validatePredicate(on, index, path, true);
|
|
8058
|
-
|
|
8059
|
-
|
|
8088
|
+
const command = validateCommand(run, at, path, { onType: "label" });
|
|
8089
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8090
|
+
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8060
8091
|
}
|
|
8061
8092
|
const packages = validatePackagesFlag(run, at, path);
|
|
8062
8093
|
const image = validateImageRef(run, at, path);
|
|
@@ -8067,7 +8098,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8067
8098
|
const replicas = validateReplicas(run, at, path);
|
|
8068
8099
|
return {
|
|
8069
8100
|
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 } }
|
|
8101
|
+
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
8102
|
};
|
|
8072
8103
|
}
|
|
8073
8104
|
function normalizeComment(on, run, index, path, state) {
|
|
@@ -8075,8 +8106,9 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8075
8106
|
if (!isNonEmptyString2(on.phrase)) {
|
|
8076
8107
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
8077
8108
|
}
|
|
8078
|
-
|
|
8079
|
-
|
|
8109
|
+
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
8110
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8111
|
+
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
8080
8112
|
}
|
|
8081
8113
|
state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
|
|
8082
8114
|
if (state.commentCounts[run.kind] > 1) {
|
|
@@ -8091,7 +8123,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8091
8123
|
const replicas = validateReplicas(run, at, path);
|
|
8092
8124
|
return {
|
|
8093
8125
|
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 } }
|
|
8126
|
+
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
8127
|
};
|
|
8096
8128
|
}
|
|
8097
8129
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -8114,8 +8146,9 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8114
8146
|
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
8147
|
}
|
|
8116
8148
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
8117
|
-
|
|
8118
|
-
|
|
8149
|
+
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
8150
|
+
if (command === void 0 && !isNonEmptyString2(run.flow)) {
|
|
8151
|
+
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
8119
8152
|
}
|
|
8120
8153
|
const packages = validatePackagesFlag(run, at, path);
|
|
8121
8154
|
const image = validateImageRef(run, at, path);
|
|
@@ -8135,7 +8168,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
8135
8168
|
all: predicate.all,
|
|
8136
8169
|
none: predicate.none
|
|
8137
8170
|
},
|
|
8138
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
|
|
8171
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions } }
|
|
8139
8172
|
};
|
|
8140
8173
|
}
|
|
8141
8174
|
function validateReviewState(on, actions, run, at, path) {
|
|
@@ -8516,14 +8549,18 @@ import { Queue } from "bullmq";
|
|
|
8516
8549
|
function makeQueue(connection) {
|
|
8517
8550
|
return new Queue(QUEUE, { connection });
|
|
8518
8551
|
}
|
|
8519
|
-
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8552
|
+
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
8520
8553
|
const minute = now.toISOString().slice(0, 16);
|
|
8521
|
-
const id = jobId ?? localJobId({ folder, flow, task, minute });
|
|
8554
|
+
const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
|
|
8522
8555
|
const data = {
|
|
8523
8556
|
kind: "local",
|
|
8524
8557
|
folder,
|
|
8525
8558
|
flow,
|
|
8526
8559
|
task,
|
|
8560
|
+
// The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
|
|
8561
|
+
// `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
|
|
8562
|
+
// job carrying it has flow/task undefined, which JSON serialization drops.
|
|
8563
|
+
...command !== void 0 && { command },
|
|
8527
8564
|
provider,
|
|
8528
8565
|
model,
|
|
8529
8566
|
maxTurns,
|
|
@@ -8714,8 +8751,8 @@ var init_docker_run = __esm({
|
|
|
8714
8751
|
});
|
|
8715
8752
|
|
|
8716
8753
|
// ../worker/src/packages.mjs
|
|
8717
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
8718
|
-
import { join } from "node:path";
|
|
8754
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8755
|
+
import { basename, dirname as dirname2, join, resolve } from "node:path";
|
|
8719
8756
|
function isNonEmptyString5(value) {
|
|
8720
8757
|
return typeof value === "string" && value.trim() !== "";
|
|
8721
8758
|
}
|
|
@@ -8739,6 +8776,55 @@ function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists =
|
|
|
8739
8776
|
return null;
|
|
8740
8777
|
}
|
|
8741
8778
|
}
|
|
8779
|
+
function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
|
|
8780
|
+
const out = { skills: [], unenumerable: [] };
|
|
8781
|
+
const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
|
|
8782
|
+
if (!manifest) return out;
|
|
8783
|
+
for (const pkg of manifest.packages) {
|
|
8784
|
+
const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
|
|
8785
|
+
let sources;
|
|
8786
|
+
try {
|
|
8787
|
+
const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
|
|
8788
|
+
const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
|
|
8789
|
+
if (pi) {
|
|
8790
|
+
const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
|
|
8791
|
+
if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
|
|
8792
|
+
out.unenumerable.push(pkg.name);
|
|
8793
|
+
continue;
|
|
8794
|
+
}
|
|
8795
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
|
|
8796
|
+
} else {
|
|
8797
|
+
sources = [join(root, "skills")];
|
|
8798
|
+
}
|
|
8799
|
+
} catch {
|
|
8800
|
+
continue;
|
|
8801
|
+
}
|
|
8802
|
+
for (const source of sources) {
|
|
8803
|
+
for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
|
|
8804
|
+
out.skills.push({ name: basename(dirname2(skillFile)), package: pkg.name, dir: pkg.dir });
|
|
8805
|
+
}
|
|
8806
|
+
}
|
|
8807
|
+
}
|
|
8808
|
+
return out;
|
|
8809
|
+
}
|
|
8810
|
+
function walkSkillFiles(source, fileExists, readDir, depth = 3) {
|
|
8811
|
+
if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
|
|
8812
|
+
const found = [];
|
|
8813
|
+
const own = join(source, "SKILL.md");
|
|
8814
|
+
if (fileExists(own)) found.push(own);
|
|
8815
|
+
if (depth === 0) return found;
|
|
8816
|
+
let children;
|
|
8817
|
+
try {
|
|
8818
|
+
children = readDir(source, { withFileTypes: true });
|
|
8819
|
+
} catch {
|
|
8820
|
+
return found;
|
|
8821
|
+
}
|
|
8822
|
+
for (const child of children) {
|
|
8823
|
+
if (!child.isDirectory?.()) continue;
|
|
8824
|
+
found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
|
|
8825
|
+
}
|
|
8826
|
+
return found;
|
|
8827
|
+
}
|
|
8742
8828
|
var PACKAGES_SUBDIR, STAGE_MANIFEST, CONTAINER_PACKAGES_ROOT, FROM_DECLARED, FROM_HOST, MAX_DIR_LENGTH;
|
|
8743
8829
|
var init_packages = __esm({
|
|
8744
8830
|
"../worker/src/packages.mjs"() {
|
|
@@ -8925,11 +9011,13 @@ function findLoopHints(text) {
|
|
|
8925
9011
|
}
|
|
8926
9012
|
return hints;
|
|
8927
9013
|
}
|
|
8928
|
-
function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
|
|
9014
|
+
function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, overlaySkills, stagedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
|
|
8929
9015
|
const triggerList2 = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
|
|
8930
9016
|
const schedulerList = Array.isArray(schedulers) ? schedulers : [];
|
|
8931
9017
|
const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
|
|
8932
9018
|
const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
|
|
9019
|
+
const overlay = overlaySkills && typeof overlaySkills === "object" && Array.isArray(overlaySkills.skills) ? overlaySkills : null;
|
|
9020
|
+
const staged = stagedSkills && typeof stagedSkills === "object" && Array.isArray(stagedSkills.skills) ? stagedSkills : null;
|
|
8933
9021
|
const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
|
|
8934
9022
|
const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
|
|
8935
9023
|
const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
|
|
@@ -8955,13 +9043,18 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
8955
9043
|
// collectGraphInputs' cap flag rides its spread into this input (review finding: this was
|
|
8956
9044
|
// hardcoded false, so the cap-reached banner could never fire on any surface).
|
|
8957
9045
|
folders: foldersTruncated === true,
|
|
8958
|
-
skills: Object.values(folders).some((f) => f?.truncated === true),
|
|
9046
|
+
skills: Object.values(folders).some((f) => f?.truncated === true) || overlay?.truncated === true || staged?.truncated === true,
|
|
8959
9047
|
edges: chainEdges?.truncated === true
|
|
8960
9048
|
},
|
|
8961
9049
|
droppedObservedEdges: 0,
|
|
8962
9050
|
// Injected dirs whose readdir failed (review finding): the OQ-022 badge is the one fact the
|
|
8963
9051
|
// 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()
|
|
9052
|
+
injectedUnreachable: Object.entries(injected).filter(([, r]) => typeof r?.unreachable === "string").map(([dir]) => dir).sort(),
|
|
9053
|
+
// The two tier-honesty counters (issue #188): an overlay whose skills/ failed to read, and the
|
|
9054
|
+
// staged packages whose pattern manifests defeat enumeration. Both feed the softened state
|
|
9055
|
+
// below AND a banner, because a tier the ladder silently skipped would read as "checked".
|
|
9056
|
+
overlayUnreachable: overlay?.unreachable === "unreadable",
|
|
9057
|
+
stagedUnenumerable: (Array.isArray(staged?.unenumerable) ? staged.unenumerable : []).filter((p) => typeof p === "string").sort()
|
|
8965
9058
|
}
|
|
8966
9059
|
};
|
|
8967
9060
|
const folderByPath = /* @__PURE__ */ new Map();
|
|
@@ -9034,6 +9127,28 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9034
9127
|
}
|
|
9035
9128
|
}
|
|
9036
9129
|
}
|
|
9130
|
+
const overlayNodeId = /* @__PURE__ */ new Map();
|
|
9131
|
+
if (overlay) {
|
|
9132
|
+
for (const skill of overlay.skills) {
|
|
9133
|
+
if (typeof skill?.name !== "string" || skill.name === "" || overlayNodeId.has(skill.name)) continue;
|
|
9134
|
+
const id = `overlay:${skill.name}`;
|
|
9135
|
+
overlayNodeId.set(skill.name, id);
|
|
9136
|
+
model.nodes.push({ id, kind: "overlay", name: skill.name });
|
|
9137
|
+
}
|
|
9138
|
+
}
|
|
9139
|
+
const stagedNodeId = /* @__PURE__ */ new Map();
|
|
9140
|
+
if (staged) {
|
|
9141
|
+
const seenStagedIds = /* @__PURE__ */ new Set();
|
|
9142
|
+
for (const skill of staged.skills) {
|
|
9143
|
+
if (typeof skill?.name !== "string" || skill.name === "" || typeof skill?.dir !== "string" || skill.dir === "") continue;
|
|
9144
|
+
const id = `staged:${skill.dir}:${skill.name}`;
|
|
9145
|
+
if (!seenStagedIds.has(id)) {
|
|
9146
|
+
seenStagedIds.add(id);
|
|
9147
|
+
model.nodes.push({ id, kind: "staged", name: skill.name, package: typeof skill.package === "string" && skill.package !== "" ? skill.package : skill.dir });
|
|
9148
|
+
}
|
|
9149
|
+
if (!stagedNodeId.has(skill.name)) stagedNodeId.set(skill.name, id);
|
|
9150
|
+
}
|
|
9151
|
+
}
|
|
9037
9152
|
const missingNode = (group, name) => {
|
|
9038
9153
|
const key = `${group.key} ${name}`;
|
|
9039
9154
|
let node = skillNode.get(key);
|
|
@@ -9044,6 +9159,14 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9044
9159
|
group.skillIds.push(node.id);
|
|
9045
9160
|
return node;
|
|
9046
9161
|
};
|
|
9162
|
+
const notAtHeadNode = (group, name, tiers) => {
|
|
9163
|
+
const node = missingNode(group, name);
|
|
9164
|
+
if (node.kind === "skill-missing") node.kind = "skill-not-at-head";
|
|
9165
|
+
if (node.kind === "skill-not-at-head") {
|
|
9166
|
+
node.tiersUnknown = [.../* @__PURE__ */ new Set([...Array.isArray(node.tiersUnknown) ? node.tiersUnknown : [], ...tiers])].sort();
|
|
9167
|
+
}
|
|
9168
|
+
return node;
|
|
9169
|
+
};
|
|
9047
9170
|
for (const t of triggerList2) {
|
|
9048
9171
|
if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
|
|
9049
9172
|
const id = `trigger:${t.index}`;
|
|
@@ -9080,6 +9203,11 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9080
9203
|
pattern: isCron ? t.pattern ?? null : null,
|
|
9081
9204
|
label: triggerMatchLabel(t),
|
|
9082
9205
|
flow: t.flow ?? null,
|
|
9206
|
+
// A command trigger's dispatched extension command (issue #189), flow's mutually-exclusive
|
|
9207
|
+
// sibling. Carried on the node so the display half (issue #188) has the fact to render; nothing
|
|
9208
|
+
// in THIS module consumes it -- a command names no SKILL.md, so the config-edge machinery below
|
|
9209
|
+
// (guarded on `typeof t.flow === "string"`) correctly draws nothing for it.
|
|
9210
|
+
command: t.command ?? null,
|
|
9083
9211
|
replicas: t.replicas ?? null,
|
|
9084
9212
|
folderKey: group?.key ?? null,
|
|
9085
9213
|
runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
|
|
@@ -9110,9 +9238,41 @@ function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, f
|
|
|
9110
9238
|
existing.isFlow = true;
|
|
9111
9239
|
model.edges.push({ from: id, to: existing.id, kind: "config" });
|
|
9112
9240
|
} else {
|
|
9113
|
-
const
|
|
9114
|
-
|
|
9115
|
-
|
|
9241
|
+
const probes = [];
|
|
9242
|
+
if (typeof t.skillsDir === "string" && t.skillsDir !== "") {
|
|
9243
|
+
const r = injected[t.skillsDir];
|
|
9244
|
+
if (!r || typeof r.unreachable === "string") probes.push({ tier: TIER_INJECTED, state: "unknown" });
|
|
9245
|
+
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}` });
|
|
9246
|
+
else probes.push({ tier: TIER_INJECTED, state: r.truncated === true ? "unknown" : "miss" });
|
|
9247
|
+
}
|
|
9248
|
+
if (overlay === null || typeof overlay.unreachable === "string") probes.push({ tier: TIER_OVERLAY, state: "unknown" });
|
|
9249
|
+
else if (overlayNodeId.has(t.flow)) probes.push({ tier: TIER_OVERLAY, state: "hit", nodeId: overlayNodeId.get(t.flow) });
|
|
9250
|
+
else probes.push({ tier: TIER_OVERLAY, state: overlay.truncated === true ? "unknown" : "miss" });
|
|
9251
|
+
if (t.packages === false) {
|
|
9252
|
+
probes.push({ tier: `${TIER_STAGED} (withheld: run.packages false)`, state: "miss" });
|
|
9253
|
+
} else if (staged === null) probes.push({ tier: TIER_STAGED, state: "unknown" });
|
|
9254
|
+
else if (stagedNodeId.has(t.flow)) probes.push({ tier: TIER_STAGED, state: "hit", nodeId: stagedNodeId.get(t.flow) });
|
|
9255
|
+
else probes.push({ tier: TIER_STAGED, state: staged.unenumerable.length > 0 || staged.truncated === true ? "unknown" : "miss" });
|
|
9256
|
+
let target = null;
|
|
9257
|
+
let blocked = false;
|
|
9258
|
+
for (const probe of probes) {
|
|
9259
|
+
if (probe.state === "hit" && !blocked) {
|
|
9260
|
+
target = probe.nodeId;
|
|
9261
|
+
break;
|
|
9262
|
+
}
|
|
9263
|
+
if (probe.state === "unknown") blocked = true;
|
|
9264
|
+
}
|
|
9265
|
+
if (target !== null) {
|
|
9266
|
+
model.edges.push({ from: id, to: target, kind: "config" });
|
|
9267
|
+
} else if (probes.some((p) => p.state === "unknown")) {
|
|
9268
|
+
const soft = notAtHeadNode(group, t.flow, probes.filter((p) => p.state === "unknown").map((p) => p.tier));
|
|
9269
|
+
model.edges.push({ from: id, to: soft.id, kind: "config" });
|
|
9270
|
+
} else {
|
|
9271
|
+
const target2 = missingNode(group, t.flow);
|
|
9272
|
+
model.edges.push({ from: id, to: target2.id, kind: "config" });
|
|
9273
|
+
const alsoChecked = probes.length > 0 ? `; also checked: ${probes.map((p) => p.tier).join(", ")}` : "";
|
|
9274
|
+
model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD${alsoChecked}` });
|
|
9275
|
+
}
|
|
9116
9276
|
}
|
|
9117
9277
|
} else if (group) {
|
|
9118
9278
|
const target = missingNode(group, t.flow);
|
|
@@ -9215,7 +9375,7 @@ function clipName(name) {
|
|
|
9215
9375
|
const s = String(name);
|
|
9216
9376
|
return s.length > 64 ? `${s.slice(0, 64)}\u2026` : s;
|
|
9217
9377
|
}
|
|
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;
|
|
9378
|
+
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
9379
|
var init_graph_model = __esm({
|
|
9220
9380
|
"src/graph-model.mjs"() {
|
|
9221
9381
|
init_flow_gate();
|
|
@@ -9228,7 +9388,7 @@ var init_graph_model = __esm({
|
|
|
9228
9388
|
GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
|
|
9229
9389
|
GRAPH_FLAGS = Object.freeze([
|
|
9230
9390
|
"no-skill",
|
|
9231
|
-
// config edge target absent
|
|
9391
|
+
// config edge target absent in EVERY checkable applicable tier (readFlowGate's precise token, now with the full ladder behind it)
|
|
9232
9392
|
"charset-invalid",
|
|
9233
9393
|
// run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
|
|
9234
9394
|
"orphan",
|
|
@@ -9242,6 +9402,26 @@ var init_graph_model = __esm({
|
|
|
9242
9402
|
"pr-spend-loop-risk"
|
|
9243
9403
|
// pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
|
|
9244
9404
|
]);
|
|
9405
|
+
GRAPH_NODE_KINDS = Object.freeze([
|
|
9406
|
+
"trigger",
|
|
9407
|
+
"skill",
|
|
9408
|
+
// committed at HEAD in an enumerated folder
|
|
9409
|
+
"skill-missing",
|
|
9410
|
+
// absent in every checkable applicable tier: the one red, dangling state
|
|
9411
|
+
"skill-unverified",
|
|
9412
|
+
// the folder itself was never readable (forge/remote): a read that never happened
|
|
9413
|
+
"skill-not-at-head",
|
|
9414
|
+
// absent at HEAD, but some lower tier was not checkable from this session
|
|
9415
|
+
"injected",
|
|
9416
|
+
// run.skillsDir working-tree skill: trigger-reachable, never AI-reachable
|
|
9417
|
+
"overlay",
|
|
9418
|
+
// deployment overlay skills/: trigger-reachable, never AI-reachable
|
|
9419
|
+
"staged"
|
|
9420
|
+
// a staged pi package's skill: trigger-reachable, never AI-reachable
|
|
9421
|
+
]);
|
|
9422
|
+
TIER_INJECTED = "injected run.skillsDir";
|
|
9423
|
+
TIER_OVERLAY = "overlay skills/";
|
|
9424
|
+
TIER_STAGED = "staged packages";
|
|
9245
9425
|
}
|
|
9246
9426
|
});
|
|
9247
9427
|
|
|
@@ -9889,6 +10069,11 @@ async function enqueueDispatchRun({
|
|
|
9889
10069
|
if (typeof flow !== "string" || flow.trim() === "") {
|
|
9890
10070
|
return { refused: "no flow \u2014 a flow is required to trigger a run" };
|
|
9891
10071
|
}
|
|
10072
|
+
if (flow.trim().startsWith("/")) {
|
|
10073
|
+
return {
|
|
10074
|
+
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)`
|
|
10075
|
+
};
|
|
10076
|
+
}
|
|
9892
10077
|
const { valkeyUrl, dispatchRunRoots, dispatchRunPerHour } = resolvePaths(env);
|
|
9893
10078
|
if (aiInvoked && !folderUnderRoots(folder, dispatchRunRoots)) {
|
|
9894
10079
|
return { refused: "folder not under PI_DISPATCH_RUN_ROOTS" };
|
|
@@ -10098,8 +10283,8 @@ async function readBudget({ url, redisFn = makeRedisClient, timeoutMs = 2500 } =
|
|
|
10098
10283
|
}
|
|
10099
10284
|
function withTimeout(promise, ms, fallback) {
|
|
10100
10285
|
let timer;
|
|
10101
|
-
const timeout = new Promise((
|
|
10102
|
-
timer = setTimeout(() =>
|
|
10286
|
+
const timeout = new Promise((resolve2) => {
|
|
10287
|
+
timer = setTimeout(() => resolve2(fallback), ms);
|
|
10103
10288
|
if (typeof timer?.unref === "function") timer.unref();
|
|
10104
10289
|
});
|
|
10105
10290
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
@@ -10193,6 +10378,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10193
10378
|
if (on === null || typeof on !== "object") return null;
|
|
10194
10379
|
const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
|
|
10195
10380
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
10381
|
+
const command = typeof run.command === "string" ? run.command : null;
|
|
10196
10382
|
const packages = run.packages !== false;
|
|
10197
10383
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
10198
10384
|
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
|
|
@@ -10208,6 +10394,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10208
10394
|
pattern: typeof on.pattern === "string" ? on.pattern : null,
|
|
10209
10395
|
folder: typeof run.folder === "string" ? run.folder : null,
|
|
10210
10396
|
flow,
|
|
10397
|
+
command,
|
|
10211
10398
|
// Optional per-cron model override (passthrough into job.data); null when the entry resolves the
|
|
10212
10399
|
// deployment default. Surfaced so the drill-in shows which schedules pin their own model.
|
|
10213
10400
|
model: typeof run.model === "string" ? run.model : null,
|
|
@@ -10218,9 +10405,9 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10218
10405
|
resume
|
|
10219
10406
|
};
|
|
10220
10407
|
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 };
|
|
10408
|
+
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
10409
|
case "comment":
|
|
10223
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10410
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, replicas, forge };
|
|
10224
10411
|
case "pull_request":
|
|
10225
10412
|
return {
|
|
10226
10413
|
type: "pull_request",
|
|
@@ -10229,6 +10416,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
10229
10416
|
all: normalizeSelector(on.all),
|
|
10230
10417
|
none: normalizeSelector(on.none),
|
|
10231
10418
|
flow,
|
|
10419
|
+
command,
|
|
10232
10420
|
packages,
|
|
10233
10421
|
image,
|
|
10234
10422
|
skillsDir,
|
|
@@ -10476,8 +10664,69 @@ function readInjectedSkills({ skillsDir, fs = nodeFs2 } = {}) {
|
|
|
10476
10664
|
}
|
|
10477
10665
|
return { skills, truncated, unreachable: null };
|
|
10478
10666
|
}
|
|
10479
|
-
function
|
|
10480
|
-
|
|
10667
|
+
function readOverlaySkills({ globalPiDir, fs = nodeFs2 } = {}) {
|
|
10668
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return { skills: [], truncated: false, unreachable: null };
|
|
10669
|
+
const dir = join2(globalPiDir, "skills");
|
|
10670
|
+
let names;
|
|
10671
|
+
try {
|
|
10672
|
+
names = fs.readdirSync(dir);
|
|
10673
|
+
} catch (err) {
|
|
10674
|
+
if (err?.code === "ENOENT") return { skills: [], truncated: false, unreachable: null };
|
|
10675
|
+
return { skills: [], truncated: false, unreachable: "unreadable" };
|
|
10676
|
+
}
|
|
10677
|
+
const valid = names.filter((n) => SKILL_NAME_RE.test(n)).sort();
|
|
10678
|
+
const truncated = valid.length > GRAPH_LIMITS.maxSkillsPerFolder;
|
|
10679
|
+
const skills = [];
|
|
10680
|
+
for (const name of valid.slice(0, GRAPH_LIMITS.maxSkillsPerFolder)) {
|
|
10681
|
+
let present = false;
|
|
10682
|
+
try {
|
|
10683
|
+
present = fs.existsSync(join2(dir, name, "SKILL.md"));
|
|
10684
|
+
} catch {
|
|
10685
|
+
present = false;
|
|
10686
|
+
}
|
|
10687
|
+
if (present) skills.push({ name });
|
|
10688
|
+
}
|
|
10689
|
+
return { skills, truncated, unreachable: null };
|
|
10690
|
+
}
|
|
10691
|
+
function readStagedSkillsList({ globalPiDir, fs = nodeFs2, readStaged = readStagedSkills } = {}) {
|
|
10692
|
+
const empty = { skills: [], unenumerable: [], truncated: false };
|
|
10693
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
10694
|
+
let result;
|
|
10695
|
+
try {
|
|
10696
|
+
result = readStaged({
|
|
10697
|
+
globalPiDir,
|
|
10698
|
+
readFile: (path) => fs.readFileSync(path, "utf8"),
|
|
10699
|
+
fileExists: (path) => fs.existsSync(path),
|
|
10700
|
+
readDir: (path, opts) => fs.readdirSync(path, opts)
|
|
10701
|
+
});
|
|
10702
|
+
} catch {
|
|
10703
|
+
return empty;
|
|
10704
|
+
}
|
|
10705
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10706
|
+
const skills = [];
|
|
10707
|
+
for (const s of Array.isArray(result?.skills) ? result.skills : []) {
|
|
10708
|
+
if (typeof s?.name !== "string" || !SKILL_NAME_RE.test(s.name)) continue;
|
|
10709
|
+
if (typeof s.dir !== "string" || s.dir === "") continue;
|
|
10710
|
+
const key = `${s.dir} ${s.name}`;
|
|
10711
|
+
if (seen.has(key)) continue;
|
|
10712
|
+
seen.add(key);
|
|
10713
|
+
skills.push({ name: s.name, package: typeof s.package === "string" && s.package !== "" ? s.package : s.dir, dir: s.dir });
|
|
10714
|
+
}
|
|
10715
|
+
return {
|
|
10716
|
+
skills: skills.slice(0, GRAPH_LIMITS.maxStagedSkills),
|
|
10717
|
+
unenumerable: (Array.isArray(result?.unenumerable) ? result.unenumerable : []).filter((p) => typeof p === "string").sort(),
|
|
10718
|
+
truncated: skills.length > GRAPH_LIMITS.maxStagedSkills
|
|
10719
|
+
};
|
|
10720
|
+
}
|
|
10721
|
+
function collectGraphInputs({
|
|
10722
|
+
triggers,
|
|
10723
|
+
globalPiDir = null,
|
|
10724
|
+
readFolder = readFolderSkills,
|
|
10725
|
+
readInjected = readInjectedSkills,
|
|
10726
|
+
readOverlay: readOverlay2 = readOverlaySkills,
|
|
10727
|
+
readStaged = readStagedSkillsList
|
|
10728
|
+
} = {}) {
|
|
10729
|
+
const out = { folderSkills: {}, injectedSkills: {}, overlaySkills: null, stagedSkills: null, foldersTruncated: false };
|
|
10481
10730
|
if (!Array.isArray(triggers)) return out;
|
|
10482
10731
|
const folders = [];
|
|
10483
10732
|
const injectedDirs = [];
|
|
@@ -10492,6 +10741,10 @@ function collectGraphInputs({ triggers, readFolder = readFolderSkills, readInjec
|
|
|
10492
10741
|
for (const dir of injectedDirs.slice(0, GRAPH_LIMITS.maxFoldersScanned)) {
|
|
10493
10742
|
out.injectedSkills[dir] = readInjected({ skillsDir: dir });
|
|
10494
10743
|
}
|
|
10744
|
+
if (typeof globalPiDir === "string" && globalPiDir !== "") {
|
|
10745
|
+
out.overlaySkills = readOverlay2({ globalPiDir });
|
|
10746
|
+
out.stagedSkills = readStaged({ globalPiDir });
|
|
10747
|
+
}
|
|
10495
10748
|
return out;
|
|
10496
10749
|
}
|
|
10497
10750
|
function forgeRepoTargets({ records } = {}) {
|
|
@@ -10582,8 +10835,10 @@ var init_read_model = __esm({
|
|
|
10582
10835
|
// distinct observed chain edges kept; beyond this the graph says "truncated"
|
|
10583
10836
|
windowDays: 30,
|
|
10584
10837
|
// run-record window the graph folds; matches the default PI_LOG_RETENTION_DAYS
|
|
10585
|
-
maxReposListed: 5
|
|
10838
|
+
maxReposListed: 5,
|
|
10586
10839
|
// repos named per forge group, from record targets; a scope label, not an inventory
|
|
10840
|
+
maxStagedSkills: 64
|
|
10841
|
+
// staged-package skills kept for display; one stage read per graph build
|
|
10587
10842
|
});
|
|
10588
10843
|
GRAPH_LS_TREE_MAX_BYTES = 1 << 20;
|
|
10589
10844
|
}
|
|
@@ -10637,7 +10892,11 @@ function normalizeModel(model) {
|
|
|
10637
10892
|
// dropped (issue #175): three surfaces of one model must not disagree about what was refused
|
|
10638
10893
|
// or unreadable. Sorted and clipped like every other array on this page.
|
|
10639
10894
|
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)
|
|
10895
|
+
injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : []).filter((d) => typeof d === "string").map((d) => clip(d, 120)).sort().slice(0, 8),
|
|
10896
|
+
// The two tier-honesty counters (issue #188), the injectedUnreachable rule applied to the
|
|
10897
|
+
// deployment-wide tiers: a tier the ladder silently skipped would read as "checked".
|
|
10898
|
+
overlayUnreachable: m.meta?.overlayUnreachable === true,
|
|
10899
|
+
stagedUnenumerable: (Array.isArray(m.meta?.stagedUnenumerable) ? m.meta.stagedUnenumerable : []).filter((d) => typeof d === "string").map((d) => clip(d, 80)).sort().slice(0, 8)
|
|
10641
10900
|
};
|
|
10642
10901
|
const folders = [];
|
|
10643
10902
|
for (const f of Array.isArray(m.folders) ? m.folders : []) {
|
|
@@ -10671,6 +10930,13 @@ function normalizeModel(model) {
|
|
|
10671
10930
|
onType: typeof n.onType === "string" ? n.onType : null,
|
|
10672
10931
|
pattern: typeof n.pattern === "string" ? n.pattern : null,
|
|
10673
10932
|
flow: typeof n.flow === "string" ? n.flow : null,
|
|
10933
|
+
// A command trigger's slash command (issue #189's node fact, #188's rendering): the tip is the
|
|
10934
|
+
// detail surface, so the full command rides, clipped like every other string on this page.
|
|
10935
|
+
command: typeof n.command === "string" ? clip(n.command, 80) : null,
|
|
10936
|
+
// The staged tier node's owning package, and the softened state's evidence: which tiers this
|
|
10937
|
+
// session could not check. Both clipped/sorted/capped so a hostile model cannot inflate a tip.
|
|
10938
|
+
package: typeof n.package === "string" ? clip(n.package, 80) : null,
|
|
10939
|
+
tiersUnknown: Array.isArray(n.tiersUnknown) ? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4) : [],
|
|
10674
10940
|
replicas: intOr(n.replicas, null),
|
|
10675
10941
|
folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
|
|
10676
10942
|
runs: intOr(n.runs, 0),
|
|
@@ -10751,6 +11017,8 @@ function layoutNormalized(norm) {
|
|
|
10751
11017
|
placedByOrig.set(n.id, p);
|
|
10752
11018
|
let g = n.folderKey === null ? null : groupIndexByKey.get(n.folderKey) ?? null;
|
|
10753
11019
|
if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
|
|
11020
|
+
if (!g && n.kind === "overlay") g = groupIndexByKey.get("~overlay") ?? addGroup("~overlay", "overlay skills (global pi dir)", "local", null, null);
|
|
11021
|
+
if (!g && n.kind === "staged") g = groupIndexByKey.get("~staged") ?? addGroup("~staged", "staged package skills", "local", null, null);
|
|
10754
11022
|
if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
|
|
10755
11023
|
g.members.push(p);
|
|
10756
11024
|
p.groupId = g.id;
|
|
@@ -11080,13 +11348,23 @@ function buildTip(n, flags, groupLabel, nowMs) {
|
|
|
11080
11348
|
if (n.kind === "trigger") {
|
|
11081
11349
|
lines.push(`trigger \xB7 ${n.onType ?? "?"} \xB7 ${n.label ?? ""}`.trim());
|
|
11082
11350
|
if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
|
|
11351
|
+
else if (n.command !== null) lines.push(`command: /${n.command}${n.replicas !== null ? ` \xD7${n.replicas}` : ""}`);
|
|
11083
11352
|
} else if (n.kind === "injected") {
|
|
11084
11353
|
lines.push(`injected skill \xB7 ${n.name ?? "?"}`);
|
|
11085
11354
|
lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
|
|
11355
|
+
} else if (n.kind === "overlay") {
|
|
11356
|
+
lines.push(`overlay skill \xB7 ${n.name ?? "?"}`);
|
|
11357
|
+
lines.push("deployment overlay skills/, trigger-reachable, never AI-reachable");
|
|
11358
|
+
} else if (n.kind === "staged") {
|
|
11359
|
+
lines.push(`staged skill \xB7 ${n.name ?? "?"}${n.package !== null ? ` \xB7 package ${n.package}` : ""}`);
|
|
11360
|
+
lines.push("staged pi package, trigger-reachable, never AI-reachable");
|
|
11086
11361
|
} else if (n.kind === "skill-missing") {
|
|
11087
11362
|
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 missing at HEAD`);
|
|
11088
11363
|
} else if (n.kind === "skill-unverified") {
|
|
11089
11364
|
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 unverified (repo not readable from this host)`);
|
|
11365
|
+
} else if (n.kind === "skill-not-at-head") {
|
|
11366
|
+
lines.push(`skill \xB7 ${n.name ?? "?"} \xB7 not committed at HEAD`);
|
|
11367
|
+
if (n.tiersUnknown.length > 0) lines.push(`not checkable from this session: ${n.tiersUnknown.join(", ")}`);
|
|
11090
11368
|
} else {
|
|
11091
11369
|
lines.push(`skill \xB7 ${n.name ?? "?"}${n.isSub ? " \xB7 sub-skill (never a flow)" : ""}`);
|
|
11092
11370
|
}
|
|
@@ -11133,6 +11411,7 @@ function nodeState(n, flagNames) {
|
|
|
11133
11411
|
return { stroke: DANGER, dash: "10,4", faded: false };
|
|
11134
11412
|
}
|
|
11135
11413
|
if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
|
|
11414
|
+
if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
|
|
11136
11415
|
if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
|
|
11137
11416
|
return { stroke: CHIP_STROKE, dash: null, faded: false };
|
|
11138
11417
|
}
|
|
@@ -11248,7 +11527,8 @@ function legendHtml(norm) {
|
|
|
11248
11527
|
row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
|
|
11249
11528
|
row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
|
|
11250
11529
|
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:
|
|
11530
|
+
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");
|
|
11531
|
+
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
11532
|
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
11533
|
rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
|
|
11254
11534
|
const honesty = [];
|
|
@@ -11260,6 +11540,8 @@ function legendHtml(norm) {
|
|
|
11260
11540
|
const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
|
|
11261
11541
|
if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
|
|
11262
11542
|
if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
|
|
11543
|
+
if (norm.meta.overlayUnreachable) honesty.push("overlay skills dir unreadable (global pi dir)");
|
|
11544
|
+
if (norm.meta.stagedUnenumerable.length > 0) honesty.push(`staged packages not enumerable (manifest patterns): ${norm.meta.stagedUnenumerable.join(", ")}`);
|
|
11263
11545
|
for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
|
|
11264
11546
|
return `<div id="legend">${rows.join("")}</div>`;
|
|
11265
11547
|
}
|
|
@@ -11406,7 +11688,10 @@ var init_graph_html = __esm({
|
|
|
11406
11688
|
skill: "\u0192",
|
|
11407
11689
|
"skill-missing": "!",
|
|
11408
11690
|
"skill-unverified": "?",
|
|
11409
|
-
|
|
11691
|
+
"skill-not-at-head": "\u22EF",
|
|
11692
|
+
injected: "+",
|
|
11693
|
+
overlay: "\u25CE",
|
|
11694
|
+
staged: "\u25A3"
|
|
11410
11695
|
});
|
|
11411
11696
|
ORANGE_FLAGS = /* @__PURE__ */ new Set(["unread", "injected-ai-trigger", "pr-spend-loop-risk"]);
|
|
11412
11697
|
DANGLING_FLAGS = /* @__PURE__ */ new Set(["no-skill", "charset-invalid"]);
|
|
@@ -38371,7 +38656,7 @@ var init_all = __esm({
|
|
|
38371
38656
|
|
|
38372
38657
|
// ../worker/src/pricing.mjs
|
|
38373
38658
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
38374
|
-
import { dirname as
|
|
38659
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
38375
38660
|
import { fileURLToPath } from "node:url";
|
|
38376
38661
|
function listPricedModels() {
|
|
38377
38662
|
const out = [];
|
|
@@ -38400,7 +38685,7 @@ function readTextFromDisk(path) {
|
|
|
38400
38685
|
}
|
|
38401
38686
|
function readPiAiVersion(readText) {
|
|
38402
38687
|
try {
|
|
38403
|
-
let dir =
|
|
38688
|
+
let dir = dirname3(fileURLToPath(import.meta.resolve("@earendil-works/pi-ai")));
|
|
38404
38689
|
for (let hops = 0; hops < 8; hops++) {
|
|
38405
38690
|
try {
|
|
38406
38691
|
const parsed = JSON.parse(readText(join4(dir, "package.json")));
|
|
@@ -38410,7 +38695,7 @@ function readPiAiVersion(readText) {
|
|
|
38410
38695
|
}
|
|
38411
38696
|
} catch {
|
|
38412
38697
|
}
|
|
38413
|
-
const parent =
|
|
38698
|
+
const parent = dirname3(dir);
|
|
38414
38699
|
if (parent === dir) break;
|
|
38415
38700
|
dir = parent;
|
|
38416
38701
|
}
|
|
@@ -38455,7 +38740,7 @@ var init_pricing = __esm({
|
|
|
38455
38740
|
});
|
|
38456
38741
|
|
|
38457
38742
|
// ../worker/src/sandbox-store.mjs
|
|
38458
|
-
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
38743
|
+
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
38459
38744
|
import { isAbsolute as isAbsolute2, join as join5, relative } from "node:path";
|
|
38460
38745
|
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
38461
38746
|
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
@@ -38472,7 +38757,7 @@ var init_sandbox_store = __esm({
|
|
|
38472
38757
|
"../worker/src/sandbox-store.mjs"() {
|
|
38473
38758
|
init_run_history();
|
|
38474
38759
|
SANDBOX_MANIFEST = "manifest.json";
|
|
38475
|
-
defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync, renameSync, rmSync, writeFileSync };
|
|
38760
|
+
defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync3, readdirSync: readdirSync2, renameSync, rmSync, writeFileSync };
|
|
38476
38761
|
}
|
|
38477
38762
|
});
|
|
38478
38763
|
|
|
@@ -38518,10 +38803,10 @@ function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, f
|
|
|
38518
38803
|
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
38519
38804
|
}
|
|
38520
38805
|
function launchSandbox({ args, spawnFn = spawn }) {
|
|
38521
|
-
return new Promise((
|
|
38806
|
+
return new Promise((resolve2) => {
|
|
38522
38807
|
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
38523
|
-
child.on("error", (err) =>
|
|
38524
|
-
child.on("close", (code) =>
|
|
38808
|
+
child.on("error", (err) => resolve2({ code: null, error: err }));
|
|
38809
|
+
child.on("close", (code) => resolve2({ code }));
|
|
38525
38810
|
});
|
|
38526
38811
|
}
|
|
38527
38812
|
var exec3, SANDBOX_NAME_PREFIX;
|
|
@@ -38606,7 +38891,7 @@ function renderTriggers({ schedulers, triggers } = {}) {
|
|
|
38606
38891
|
return out.join("\n");
|
|
38607
38892
|
}
|
|
38608
38893
|
function triggerLine(t) {
|
|
38609
|
-
const flow = t?.flow ?? "-";
|
|
38894
|
+
const flow = t?.flow ?? commandSlashLabel(t) ?? "-";
|
|
38610
38895
|
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
38611
38896
|
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
38612
38897
|
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
@@ -38630,6 +38915,9 @@ function triggerLine(t) {
|
|
|
38630
38915
|
return "(unknown trigger)";
|
|
38631
38916
|
}
|
|
38632
38917
|
}
|
|
38918
|
+
function commandSlashLabel(t) {
|
|
38919
|
+
return typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : null;
|
|
38920
|
+
}
|
|
38633
38921
|
function ruleClauses(rule) {
|
|
38634
38922
|
const clauses = [];
|
|
38635
38923
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -39574,7 +39862,7 @@ function matchColored(t, styler) {
|
|
|
39574
39862
|
}
|
|
39575
39863
|
function targetColored(t, styler) {
|
|
39576
39864
|
const arrow = styler.fg("dim", "\u2192");
|
|
39577
|
-
const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
|
|
39865
|
+
const flow = styler.bold(styler.fg("text", t?.flow ?? commandSlashLabel(t) ?? "-"));
|
|
39578
39866
|
if (t?.type === "cron") {
|
|
39579
39867
|
const base = t.folder ? String(t.folder).split(/[/\\]/).filter(Boolean).pop() ?? "" : "";
|
|
39580
39868
|
const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
|
|
@@ -39678,7 +39966,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
39678
39966
|
const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
|
|
39679
39967
|
const blank = () => out.push(styler.cell("", inner));
|
|
39680
39968
|
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 ?? "-"}`));
|
|
39969
|
+
let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `\u2192 ${t.flow ?? commandSlashLabel(t) ?? "-"}`));
|
|
39682
39970
|
if (t.type === "cron" && sched) {
|
|
39683
39971
|
const healthy = !sched.overdueMs;
|
|
39684
39972
|
header += " " + (healthy ? styler.fg("success", "\u2714 healthy") : styler.fg("warning", `\u26A0 overdue ${formatDuration(sched.overdueMs)}`));
|
|
@@ -39714,6 +40002,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
39714
40002
|
out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
|
|
39715
40003
|
out.push(kv("model", "deployment default", "dim"));
|
|
39716
40004
|
}
|
|
40005
|
+
if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
|
|
39717
40006
|
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
39718
40007
|
out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
|
|
39719
40008
|
out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
|
|
@@ -40096,16 +40385,16 @@ async function runAttached(ctx, { title, argv0, args, cwd, env, shell, spawnFn =
|
|
|
40096
40385
|
${title}
|
|
40097
40386
|
|
|
40098
40387
|
`);
|
|
40099
|
-
outcome = await new Promise((
|
|
40388
|
+
outcome = await new Promise((resolve2) => {
|
|
40100
40389
|
let child;
|
|
40101
40390
|
try {
|
|
40102
40391
|
child = spawnFn(argv0, args, { stdio: "inherit", cwd, env, ...shell ? { shell: true } : {} });
|
|
40103
40392
|
} catch (err) {
|
|
40104
|
-
|
|
40393
|
+
resolve2({ code: null, error: err });
|
|
40105
40394
|
return;
|
|
40106
40395
|
}
|
|
40107
|
-
child.on("error", (err) =>
|
|
40108
|
-
child.on("close", (code) =>
|
|
40396
|
+
child.on("error", (err) => resolve2({ code: null, error: err }));
|
|
40397
|
+
child.on("close", (code) => resolve2({ code }));
|
|
40109
40398
|
});
|
|
40110
40399
|
} catch (err) {
|
|
40111
40400
|
outcome = { code: null, error: err };
|
|
@@ -40556,8 +40845,8 @@ var init_setup_wizard = __esm({
|
|
|
40556
40845
|
init_deployment_pointer();
|
|
40557
40846
|
init_read_model();
|
|
40558
40847
|
init_index();
|
|
40559
|
-
RUNTIME_VERSION = "0.
|
|
40560
|
-
RECEIVER_VERSION = "0.
|
|
40848
|
+
RUNTIME_VERSION = "1.0.0";
|
|
40849
|
+
RECEIVER_VERSION = "1.0.0";
|
|
40561
40850
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
40562
40851
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
40563
40852
|
NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
|
|
@@ -40699,7 +40988,7 @@ function registerTools(pi) {
|
|
|
40699
40988
|
pi.registerTool({
|
|
40700
40989
|
name: "dispatch_run",
|
|
40701
40990
|
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.",
|
|
40991
|
+
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
40992
|
executionMode: "sequential",
|
|
40704
40993
|
parameters: typebox_exports.Object({ folder: typebox_exports.String(), flow: typebox_exports.String(), task: typebox_exports.String() }),
|
|
40705
40994
|
async execute(_toolCallId, params) {
|
|
@@ -41186,7 +41475,7 @@ async function assembleGraph(paths) {
|
|
|
41186
41475
|
return buildGraphModel({
|
|
41187
41476
|
triggers,
|
|
41188
41477
|
schedulers: Array.isArray(schedulers) ? schedulers : [],
|
|
41189
|
-
...collectGraphInputs({ triggers: triggerList2 }),
|
|
41478
|
+
...collectGraphInputs({ triggers: triggerList2, globalPiDir: paths.globalPiDir }),
|
|
41190
41479
|
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList2.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
|
|
41191
41480
|
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count, triggerTypes: Object.fromEntries(triggerList2.map((t) => [t.index, t.type])) }),
|
|
41192
41481
|
chainEdges: observedChainEdges({ records: recs }),
|
|
@@ -41395,7 +41684,7 @@ could not start docker: ${error.message}
|
|
|
41395
41684
|
}
|
|
41396
41685
|
}
|
|
41397
41686
|
function pauseForMessage() {
|
|
41398
|
-
return new Promise((
|
|
41687
|
+
return new Promise((resolve2) => setTimeout(resolve2, 2500));
|
|
41399
41688
|
}
|
|
41400
41689
|
function splitWords(s) {
|
|
41401
41690
|
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": "0.
|
|
3
|
+
"version": "1.0.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",
|