docs: add runner github visibility handoff
This commit is contained in:
@@ -20,6 +20,14 @@ heavyweight e2e run by itself.
|
||||
- UniDesk services may support scheduling, CI, or CD only. They are not accepted
|
||||
as substitutes for HWLAB runtime services.
|
||||
|
||||
## Runner Issue Visibility Handoff
|
||||
|
||||
GitHub issue and PR threads are auxiliary context only. The runner prompt must be self-contained and must include the full task objective, background, constraints, acceptance, and validation commands.
|
||||
|
||||
Do not assume issue comments are readable in the runner. If a task depends on GitHub visibility, use `docs/reference/runner-issue-visibility-handoff.md` as the stable prompt contract and run `node scripts/runner-issue-visibility-preflight.mjs` before queueing the work.
|
||||
|
||||
The current runner class may have git transport access while GitHub REST issue and PR reads are blocked. Treat that as a prompt-handoff requirement, not a reason to omit context from the prompt.
|
||||
|
||||
## Hard Stop Actions
|
||||
|
||||
Stop the run immediately and classify the event as a safety blocker if any
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# HWLAB Runner GitHub Visibility Handoff
|
||||
|
||||
This contract records the runner visibility rule that surfaced in `pikasTech/HWLAB#51` and supports the downstream work behind `#7`, `#42`, and `#39`.
|
||||
|
||||
GitHub issues are auxiliary context only. The task prompt must carry the full objective, background, constraints, acceptance criteria, and validation commands. Do not assume issue comments, PR discussion, or GitHub REST issue data are readable inside the runner unless a preflight proves it.
|
||||
|
||||
## Prompt Contract
|
||||
|
||||
Use this shape when queueing work for the runner:
|
||||
|
||||
```md
|
||||
Repository: pikasTech/HWLAB
|
||||
Branch: main
|
||||
Task: <full task objective>
|
||||
Issue context: <paste the relevant issue summary here, do not rely on live issue reads>
|
||||
|
||||
Background
|
||||
- <current runner facts that matter>
|
||||
- <upstream context or blocked assumptions>
|
||||
|
||||
Constraints
|
||||
- <hard repo or environment limits>
|
||||
- <forbidden actions>
|
||||
|
||||
Acceptance
|
||||
- <what must exist when the task is done>
|
||||
- <what must not change>
|
||||
|
||||
Validation
|
||||
- <exact commands or checks>
|
||||
|
||||
Final response
|
||||
- commit id
|
||||
- changed paths
|
||||
- validation commands
|
||||
- upstream repair advice, if the runner still lacks GitHub visibility
|
||||
```
|
||||
|
||||
## Required Prompt Rules
|
||||
|
||||
- The prompt must be self-contained.
|
||||
- The prompt must not depend on unread issue comments.
|
||||
- The prompt must include the repo path, branch, objective, acceptance, and constraints.
|
||||
- The prompt must include any runner-specific limitations that affect the work.
|
||||
- The prompt must include the exact validation commands the runner should execute.
|
||||
|
||||
## Known Runner Contract
|
||||
|
||||
The D601 Code Queue runner class may have git transport access while GitHub REST issue and PR visibility is unavailable. In the confirmed #42 follow-up path, `git fetch` and `git ls-remote` worked, but GitHub repo, issue, and pull-request REST reads returned 404, `gh` was not installed, and `GH_TOKEN` / `GITHUB_TOKEN` were absent.
|
||||
|
||||
Treat that as a prompt-handoff requirement, not as a reason to omit task context.
|
||||
|
||||
## Read-Only Preflight
|
||||
|
||||
Use the companion preflight script to check the current runner visibility:
|
||||
|
||||
```sh
|
||||
node scripts/runner-issue-visibility-preflight.mjs
|
||||
```
|
||||
|
||||
The preflight must stay read-only and must not print token values.
|
||||
|
||||
## Upstream Repair Suggestions
|
||||
|
||||
- Add `gh` to the runner image or another GitHub visibility helper with stable CLI access.
|
||||
- Inject a read-only `GH_TOKEN` or `GITHUB_TOKEN` into the runner environment when issue/PR visibility is required.
|
||||
- Add a queue preflight that checks git transport, GitHub repo, issue, and PR visibility before dispatch.
|
||||
- Fail fast when GitHub visibility is missing, and require the prompt to carry the full task context.
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@
|
||||
"web:build": "node web/hwlab-cloud-web/scripts/build.mjs",
|
||||
"dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight",
|
||||
"dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish",
|
||||
"d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs"
|
||||
"d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs",
|
||||
"runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { runRunnerIssueVisibilityPreflight } from "./src/runner-issue-visibility-preflight.mjs";
|
||||
|
||||
try {
|
||||
const result = await runRunnerIssueVisibilityPreflight(process.argv.slice(2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({
|
||||
reportKind: "runner-gh-visibility-preflight",
|
||||
issue: "pikasTech/HWLAB#51",
|
||||
conclusion: "blocked",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}, null, 2));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const issue = "pikasTech/HWLAB#51";
|
||||
const supports = ["pikasTech/HWLAB#7", "pikasTech/HWLAB#42", "pikasTech/HWLAB#39"];
|
||||
const defaultTimeoutMs = 5000;
|
||||
const githubApiBase = "https://api.github.com";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
timeoutMs: defaultTimeoutMs,
|
||||
help: false
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--timeout-ms") {
|
||||
args.timeoutMs = Number.parseInt(argv[++index], 10);
|
||||
} else if (arg === "--help") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(Number.isInteger(args.timeoutMs) && args.timeoutMs > 0, "--timeout-ms must be positive");
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return "Usage: node scripts/runner-issue-visibility-preflight.mjs [--timeout-ms 5000]";
|
||||
}
|
||||
|
||||
function commandLine(command, args) {
|
||||
return [command, ...args].join(" ");
|
||||
}
|
||||
|
||||
async function run(command, args = [], options = {}) {
|
||||
const commandText = commandLine(command, args);
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: repoRoot,
|
||||
timeout: options.timeoutMs ?? defaultTimeoutMs,
|
||||
maxBuffer: 1024 * 1024
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
command: commandText,
|
||||
exitCode: 0,
|
||||
stdout: result.stdout.trim(),
|
||||
stderr: result.stderr.trim()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
command: commandText,
|
||||
exitCode: typeof error.code === "number" ? error.code : 1,
|
||||
stdout: String(error.stdout ?? "").trim(),
|
||||
stderr: String(error.stderr ?? "").trim(),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readRemoteUrl() {
|
||||
return run("git", ["remote", "get-url", "origin"]);
|
||||
}
|
||||
|
||||
async function readHeadCommit() {
|
||||
return run("git", ["rev-parse", "--short=12", "HEAD"]);
|
||||
}
|
||||
|
||||
async function probeGitTransport(timeoutMs) {
|
||||
const result = await run("git", ["ls-remote", "origin", "main"], { timeoutMs });
|
||||
const line = result.stdout.split("\n").find(Boolean) ?? "";
|
||||
const [commitId, ref] = line.split("\t");
|
||||
return {
|
||||
...result,
|
||||
ref: ref ?? null,
|
||||
commitId: commitId ?? null,
|
||||
reachable: result.ok && Boolean(commitId) && Boolean(ref)
|
||||
};
|
||||
}
|
||||
|
||||
function parseGithubSlug(remoteUrl) {
|
||||
const patterns = [
|
||||
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/iu,
|
||||
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/iu,
|
||||
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/iu
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = remoteUrl.match(pattern);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function probeGithubEndpoint(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
"user-agent": "hwlab-runner-gh-visibility-preflight"
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = await response.text();
|
||||
return {
|
||||
url,
|
||||
reachable: response.ok,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
bodyPreview: body.trim().slice(0, 240)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
url,
|
||||
reachable: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGh() {
|
||||
return run("gh", ["--version"]);
|
||||
}
|
||||
|
||||
function tokenPresence(name) {
|
||||
return Boolean(process.env[name] && String(process.env[name]).trim().length > 0);
|
||||
}
|
||||
|
||||
function blocker(type, scope, summary, nextTask) {
|
||||
const entry = {
|
||||
type,
|
||||
scope,
|
||||
status: "open",
|
||||
summary
|
||||
};
|
||||
if (nextTask) {
|
||||
entry.nextTask = nextTask;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function promptContract() {
|
||||
return {
|
||||
issueIsAuxiliaryOnly: true,
|
||||
selfContainedPromptRequired: true,
|
||||
requiredSections: [
|
||||
"repository",
|
||||
"branch",
|
||||
"task",
|
||||
"background",
|
||||
"constraints",
|
||||
"acceptance",
|
||||
"validation",
|
||||
"final-response"
|
||||
],
|
||||
mustInclude: [
|
||||
"full task objective",
|
||||
"relevant background facts",
|
||||
"hard constraints",
|
||||
"acceptance criteria",
|
||||
"exact validation commands",
|
||||
"issue context pasted into the prompt when needed"
|
||||
],
|
||||
mustNotAssume: [
|
||||
"issue comments are readable in the runner",
|
||||
"GitHub REST issue visibility is available",
|
||||
"GitHub REST pull-request visibility is available",
|
||||
"gh is installed",
|
||||
"GH_TOKEN or GITHUB_TOKEN is present"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunnerIssueVisibilityPreflight(argv) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help) {
|
||||
return {
|
||||
reportKind: "runner-gh-visibility-preflight",
|
||||
usage: usage()
|
||||
};
|
||||
}
|
||||
|
||||
const [remoteUrl, headCommit, gitTransport, ghResult] = await Promise.all([
|
||||
readRemoteUrl(),
|
||||
readHeadCommit(),
|
||||
probeGitTransport(args.timeoutMs),
|
||||
checkGh()
|
||||
]);
|
||||
|
||||
const repoSlug = remoteUrl.ok ? parseGithubSlug(remoteUrl.stdout) : null;
|
||||
const tokenFlags = {
|
||||
GH_TOKEN: tokenPresence("GH_TOKEN"),
|
||||
GITHUB_TOKEN: tokenPresence("GITHUB_TOKEN")
|
||||
};
|
||||
|
||||
const github = {
|
||||
repo: null,
|
||||
issues: null,
|
||||
pulls: null
|
||||
};
|
||||
|
||||
if (repoSlug) {
|
||||
github.repo = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}`, args.timeoutMs);
|
||||
github.issues = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}/issues?per_page=1`, args.timeoutMs);
|
||||
github.pulls = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}/pulls?per_page=1`, args.timeoutMs);
|
||||
}
|
||||
|
||||
const blockers = [];
|
||||
if (!remoteUrl.ok) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
"environment_blocker",
|
||||
"git-origin",
|
||||
`Unable to read the origin remote URL: ${remoteUrl.stderr || remoteUrl.error || "unknown error"}.`,
|
||||
"Fix the local git remote before queueing a runner task."
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!gitTransport.reachable) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
"environment_blocker",
|
||||
"git-transport",
|
||||
`git ls-remote origin main is not reachable from this runner: ${gitTransport.stderr || gitTransport.error || "unknown error"}.`,
|
||||
"Restore git transport reachability before relying on runner-side GitHub visibility."
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!repoSlug) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
"observability_blocker",
|
||||
"github-remote-parse",
|
||||
`Could not parse a GitHub repo slug from origin URL ${JSON.stringify(remoteUrl.stdout || "")}.`,
|
||||
"Use a GitHub origin remote URL that can be mapped to owner/repo."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
for (const [scope, result] of [
|
||||
["github-repo", github.repo],
|
||||
["github-issues", github.issues],
|
||||
["github-pulls", github.pulls]
|
||||
]) {
|
||||
if (result && !result.reachable) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
"observability_blocker",
|
||||
scope,
|
||||
`${scope} is not reachable from this runner${typeof result.status === "number" ? ` (HTTP ${result.status})` : `: ${result.error || "unknown error"}`}.`,
|
||||
"Do not assume issue or PR text is available; keep the prompt self-contained."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const conclusion = blockers.length === 0 ? "ready" : "blocked";
|
||||
const upstreamRecommendations = [];
|
||||
if (ghResult.ok === false) {
|
||||
upstreamRecommendations.push("Add gh to the runner image or another supported GitHub visibility helper.");
|
||||
}
|
||||
if (!tokenFlags.GH_TOKEN && !tokenFlags.GITHUB_TOKEN) {
|
||||
upstreamRecommendations.push("Inject a read-only GH_TOKEN or GITHUB_TOKEN when GitHub issue or PR reads are required.");
|
||||
}
|
||||
if (blockers.some((item) => item.scope.startsWith("github-") || item.scope === "git-transport")) {
|
||||
upstreamRecommendations.push("Add a queue preflight that checks git transport, repo, issue, and PR visibility before dispatch.");
|
||||
upstreamRecommendations.push("Fail fast when GitHub visibility is missing and require the prompt to include the full task context.");
|
||||
}
|
||||
|
||||
return {
|
||||
reportKind: "runner-gh-visibility-preflight",
|
||||
reportVersion: "v1",
|
||||
issue,
|
||||
supports,
|
||||
repoRoot,
|
||||
repoSlug,
|
||||
generatedFromCommit: headCommit.ok ? headCommit.stdout : null,
|
||||
conclusion,
|
||||
promptContract: promptContract(),
|
||||
checks: {
|
||||
remoteUrl,
|
||||
headCommit,
|
||||
gitTransport,
|
||||
gh: {
|
||||
available: ghResult.ok,
|
||||
command: ghResult.command,
|
||||
exitCode: ghResult.exitCode,
|
||||
stderr: ghResult.stderr,
|
||||
error: ghResult.error || null
|
||||
},
|
||||
tokenPresence: tokenFlags,
|
||||
github
|
||||
},
|
||||
blockers,
|
||||
upstreamRecommendations,
|
||||
validationCommands: [
|
||||
"node --check scripts/runner-issue-visibility-preflight.mjs",
|
||||
"node --check scripts/src/runner-issue-visibility-preflight.mjs",
|
||||
"node scripts/runner-issue-visibility-preflight.mjs"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user