@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
451 lines • 19.1 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { FlagView } from "../../../spec/flag_view.js";
import { UsageError } from "../../../errors.js";
import { StreamRefusal } from "../../../../rs/errors.js";
import { dispatchWorkflow, getRun, getWorkflow, jobLog, listJobs, listRuns, listWorkflows, rerun, rerunJob, runLogArchive, workflowContent, } from "../../../../core/github/actions.js";
import { GitHubApiError } from "../../../../core/github/client.js";
import { viewRepo } from "../../../../core/github/repo.js";
import { IOResult, materialize } from "../../../../io/types.js";
import { openStream, serveRequests } from "../../../../rs/stream.js";
import { camel, ghBool, listLimit, readCliFile, repoFor, textOut, textValue, typedOut, } from "./accessor.js";
import { concat } from "../../../../utils/bytes.js";
import { records } from "../../../../utils/records.js";
const RUN_FIELDS = [
'attempt',
'conclusion',
'createdAt',
'databaseId',
'displayTitle',
'event',
'headBranch',
'headSha',
'name',
'number',
'startedAt',
'status',
'updatedAt',
'url',
'workflowDatabaseId',
'workflowName',
];
const WORKFLOW_FIELDS = ['id', 'name', 'path', 'state'];
// The conclusions gh's `--log-failed` keeps (run/shared IsFailureState).
const FAILURE_STATES = new Set(['action_required', 'failure', 'startup_failure', 'timed_out']);
// How many jobs gh will fetch one by one when the archive lacks their logs.
const MAX_API_LOG_FETCHERS = 25;
// gh's cap on a job name in an archive path, in UTF-16 code units, since the
// server that writes the archive truncates in C#.
const JOB_NAME_MAX_LENGTH = 90;
const ENC = new TextEncoder();
const NEWLINE = 0x0a;
const RETURN = 0x0d;
function run(value) {
const row = camel(value);
const result = row !== null && typeof row === 'object' ? row : {};
if ('id' in result) {
result.databaseId = result.id;
delete result.id;
}
if ('htmlUrl' in result) {
result.url = result.htmlUrl;
delete result.htmlUrl;
}
if ('runAttempt' in result) {
result.attempt = result.runAttempt;
delete result.runAttempt;
}
if ('runNumber' in result) {
result.number = result.runNumber;
delete result.runNumber;
}
if ('workflowId' in result) {
result.workflowDatabaseId = result.workflowId;
delete result.workflowId;
}
result.workflowName ??= result.name ?? '';
result.startedAt ??= result.runStartedAt;
return result;
}
function workflow(value) {
const row = camel(value);
return row !== null && typeof row === 'object' ? row : {};
}
export async function runListCmd(accessor, _paths, _texts, ctx) {
const fl = new FlagView(ctx.flags);
const params = {};
for (const [flag, key] of [
['branch', 'branch'],
['commit', 'head_sha'],
['event', 'event'],
['status', 'status'],
['user', 'actor'],
['created', 'created'],
]) {
const value = fl.asStr(flag);
if (value !== undefined && value !== '')
params[key] = value;
}
const rows = (await listRuns(accessor.transport, repoFor(accessor, fl), params, listLimit(fl, 20), fl.asStr('workflow'))).map(run);
const human = rows
.map((row) => `${textValue(row.status)}\t${textValue(row.conclusion)}\t${textValue(row.displayTitle)}\t${textValue(row.workflowName)}\t${textValue(row.headBranch)}\t${textValue(row.event)}\t${textValue(row.databaseId)}\n`)
.join('');
return typedOut(rows, fl, human, RUN_FIELDS, ctx);
}
export async function runViewCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const raw = texts[0] ?? '';
if (!/^\d+$/.test(raw))
throw new Error('a run ID is required in noninteractive mode');
const logs = ghBool(fl, 'log');
const failedOnly = ghBool(fl, 'log_failed');
if (logs && failedOnly)
throw new UsageError('specify only one of --log or --log-failed', 1);
const transport = accessor.transport;
const ref = repoFor(accessor, fl);
const row = run(await getRun(transport, ref, Number(raw)));
// `--json` is answered first, as gh's exporter is.
if ((logs || failedOnly) && fl.asStr('json') === undefined) {
return [await runLog(transport, ref, row, failedOnly), new IOResult()];
}
const human = `title:\t${textValue(row.displayTitle)}\nworkflow:\t${textValue(row.workflowName)}\nstatus:\t${textValue(row.status)}\nconclusion:\t${textValue(row.conclusion)}\nbranch:\t${textValue(row.headBranch)}\nevent:\t${textValue(row.event)}\n`;
const out = await typedOut(row, fl, human, RUN_FIELDS, ctx);
if (out !== null &&
ghBool(fl, 'exit_status') &&
row.conclusion !== null &&
row.conclusion !== undefined &&
row.conclusion !== '' &&
row.conclusion !== 'success') {
out[1].exitCode = 1;
}
return out;
}
/**
* A job's name as the archive spells it (gh's getJobNameForLogFilename): the
* `/` and `:` the server drops, cut to 90 UTF-16 code units the way C# cuts a
* string, a half surrogate pair read as U+FFFD, and trimmed.
*/
function logName(name) {
const cut = name.replace(/[/:]/g, '').slice(0, JOB_NAME_MAX_LENGTH);
return cut
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD')
.trim();
}
function quoted(text) {
return text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
}
// A run's log archive read from memory by mirage-rs's unzip, with its exit
// status: its stored names (index), or the entries it picks.
async function unzipped(archive, config) {
const engine = await openStream('unzip', { archive: '', ...config });
// The engine reads the archive a window at a time, each answered whole.
const host = {
errors: { append: () => Promise.resolve() },
serve: (request) => {
if (request.op === 'fill')
return Promise.resolve({ reply: { size: archive.length } });
if (request.op !== 'read')
return Promise.resolve({ reply: {} });
const skip = Number(request.skip ?? 0);
const data = archive.subarray(skip, skip + Number(request.size ?? 0));
return Promise.resolve({ reply: {}, data });
},
};
try {
const out = await materialize(serveRequests(engine, host));
return [engine.status(), out];
}
finally {
engine.close();
}
}
// Entries `picks` of a run's log archive, read in one pass over its
// directory a window at a time: each frame a tag (0 a window, 1 the entry
// whole, 2 not) and its four-byte length. Null for an entry that did not
// decode whole.
async function members(archive, picks) {
const [, framed] = await unzipped(archive, { picks: [...picks] });
const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
const read = new Map();
let at = 0;
for (const pick of picks) {
const windows = [];
let tag = framed[at];
while (tag === 0) {
const end = at + 5 + view.getUint32(at + 1, true);
windows.push(framed.subarray(at + 5, end));
at = end;
tag = framed[at];
}
at += 5;
read.set(pick, tag === 1 ? concat(windows) : null);
}
return read;
}
// The archive entry gh reads for a job, or one of its steps: the first whose
// name matches, in the archive's own order.
function entryFor(names, pattern) {
const at = names.findIndex((name) => pattern.test(name));
return at < 0 ? undefined : at;
}
// A log's lines as bufio.Scanner splits them: at each newline, a carriage
// return before it dropped, and no empty line after a final newline.
function logLines(data) {
const lines = [];
let start = 0;
while (start < data.length) {
let end = data.indexOf(NEWLINE, start);
const next = end < 0 ? data.length : end + 1;
if (end < 0)
end = data.length;
lines.push(data.subarray(start, end > start && data[end - 1] === RETURN ? end - 1 : end));
start = next;
}
return lines;
}
async function apiJobLog(transport, ref, id) {
try {
return await jobLog(transport, ref, id);
}
catch (err) {
if (err instanceof GitHubApiError && err.status === 404) {
throw new Error(`log not found: ${String(id)}`);
}
throw err;
}
}
/**
* The segments gh prints for a run's log (run/view populateLogSegments):
* per job, its steps' own files when the archive has any, otherwise the
* job's whole log from the archive, otherwise the job's log fetched on its
* own, at most 25 of those. A skipped job prints nothing, and `--log-failed`
* keeps only failed jobs and, within them, failed steps.
*/
function logSegments(transport, ref, jobs, names, failedOnly) {
const segments = [];
let fetchers = 0;
for (const job of jobs) {
const conclusion = textValue(job.conclusion);
if (conclusion === 'skipped')
continue;
if (failedOnly && !FAILURE_STATES.has(conclusion))
continue;
const title = textValue(job.name);
const name = quoted(logName(title));
const steps = records(job.steps);
const stepFile = (step) => entryFor(names, new RegExp(`^${name}\\/${textValue(step.number)}_.*\\.txt$`));
if (steps.some((step) => stepFile(step) !== undefined)) {
const ordered = [...steps].sort((a, b) => Number(a.number) - Number(b.number));
for (const step of ordered) {
if (failedOnly && !FAILURE_STATES.has(textValue(step.conclusion)))
continue;
const file = stepFile(step);
if (file !== undefined)
segments.push({ job: title, step: textValue(step.name), read: file });
}
continue;
}
const file = entryFor(names, new RegExp(`^\\d+_${name}\\.txt$`)) ??
entryFor(names, new RegExp(`^-\\d+_${name}\\.txt$`));
if (file !== undefined) {
segments.push({ job: title, step: 'UNKNOWN STEP', read: file });
continue;
}
const id = Number(job.id);
segments.push({ job: title, step: 'UNKNOWN STEP', read: () => apiJobLog(transport, ref, id) });
fetchers += 1;
if (fetchers > MAX_API_LOG_FETCHERS) {
throw new Error('too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option');
}
}
return segments;
}
/**
* `gh run view --log` and `--log-failed`: the run's jobs, then, once the run
* is complete, its log archive, printed a line at a time behind the job and
* step it came from. A run still going is refused before any log is asked
* for, in gh's words.
*/
async function runLog(transport, ref, row, failedOnly) {
const id = Number(row.databaseId);
const jobs = await listJobs(transport, ref, id);
if (row.status !== 'completed') {
throw new Error(`run ${String(id)} is still in progress; logs will be available when it is complete`);
}
let archive;
try {
archive = await runLogArchive(transport, ref, id);
}
catch (err) {
if (!(err instanceof GitHubApiError))
throw err;
throw new Error(`failed to get run log: ${err.status === 404 ? 'log not found' : err.message}`);
}
const [status, index] = await unzipped(archive, { index: true });
if (status !== 0)
throw new Error('failed to get run log: zip: not a valid zip file');
// Each stored name behind its two-byte length, so any byte may be in one.
const names = [];
const view = new DataView(index.buffer, index.byteOffset, index.byteLength);
for (let at = 0; at < index.length;) {
const end = at + 2 + view.getUint16(at, true);
names.push(new TextDecoder().decode(index.subarray(at + 2, end)));
at = end;
}
const segments = logSegments(transport, ref, jobs, names, failedOnly);
const read = await members(archive, segments.flatMap((segment) => (typeof segment.read === 'number' ? [segment.read] : [])));
const printed = [];
for (const segment of segments) {
let data;
try {
if (typeof segment.read === 'number') {
const member = read.get(segment.read) ?? null;
if (member === null)
throw new Error('zip: checksum error');
data = member;
}
else {
data = await segment.read();
}
}
catch (err) {
if (!(err instanceof Error) || printed.length === 0)
throw err;
throw new StreamRefusal(err.message, concat(printed));
}
const prefix = ENC.encode(`${segment.job}\t${segment.step}\t`);
for (const line of logLines(data))
printed.push(prefix, line, ENC.encode('\n'));
}
return concat(printed);
}
export async function runRerunCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const raw = texts[0] ?? '';
if (!/^\d+$/.test(raw))
throw new Error('a run ID is required in noninteractive mode');
const transport = accessor.transport;
const ref = repoFor(accessor, fl);
const job = fl.asStr('job');
if (job !== undefined && job !== '') {
if (!/^\d+$/.test(job))
throw new Error('--job expects a numeric job ID');
await rerunJob(transport, ref, Number(job), ghBool(fl, 'debug'));
}
else {
await rerun(transport, ref, Number(raw), ghBool(fl, 'failed') ? 'rerun-failed-jobs' : 'rerun', ghBool(fl, 'debug') ? { enable_debug_logging: true } : undefined);
}
return textOut('');
}
export async function workflowListCmd(accessor, _paths, _texts, ctx) {
const fl = new FlagView(ctx.flags);
const rows = (await listWorkflows(accessor.transport, repoFor(accessor, fl), listLimit(fl, 50), ghBool(fl, 'all') ? undefined : (row) => row.state === 'active')).map(workflow);
const human = rows
.map((row) => `${textValue(row.name)}\t${textValue(row.state)}\t${textValue(row.id)}\n`)
.join('');
return typedOut(rows, fl, human, WORKFLOW_FIELDS, ctx);
}
export async function workflowViewCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const id = texts[0] ?? '';
if (id === '')
throw new Error('a workflow ID, name, or filename is required');
const yaml = ghBool(fl, 'yaml');
const gitRef = fl.asStr('ref') ?? '';
if (!yaml && gitRef !== '')
throw new UsageError('`--yaml` required when specifying `--ref`', 1);
const transport = accessor.transport;
const ref = repoFor(accessor, fl);
const row = workflow(await getWorkflow(transport, ref, id));
if (!yaml) {
return textOut(`${textValue(row.name)} - ${textValue(row.state)}\nID: ${textValue(row.id)}\nFile: ${textValue(row.path)}\n`);
}
return [await workflowYaml(transport, ref, textValue(row.path), gitRef), new IOResult()];
}
/**
* `gh workflow view --yaml`: the workflow's file, read from the repository at
* `--ref` or the default branch and printed as it is, with a newline added
* when it ends without one. A file the ref lacks is refused in gh's words.
*/
async function workflowYaml(transport, ref, path, gitRef) {
const base = path.slice(path.lastIndexOf('/') + 1);
let content;
try {
content = await workflowContent(transport, ref, path, gitRef);
}
catch (err) {
if (!(err instanceof GitHubApiError))
throw err;
if (err.status !== 404)
throw new Error(`could not get workflow file content: ${err.message}`);
throw new Error(gitRef === ''
? `could not find workflow file ${base}, try specifying a branch or tag using \`--ref\``
: `could not find workflow file ${base} on ${gitRef}, try specifying a different ref`);
}
return content.at(-1) === NEWLINE ? content : concat([content, ENC.encode('\n')]);
}
async function workflowInputs(ctx, fl) {
if (ghBool(fl, 'json')) {
if (ctx.stdin === null)
throw new Error('--json needs standard input');
let value;
try {
value = JSON.parse(new TextDecoder().decode(await materialize(ctx.stdin)));
}
catch (err) {
if (err instanceof SyntaxError)
throw new Error(`invalid JSON from standard input: ${err.message}`);
throw err;
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('workflow inputs must be a JSON object');
}
return value;
}
const inputs = {};
for (const pair of fl.asList('raw_field')) {
const at = pair.indexOf('=');
if (at < 0)
throw new Error(`expected "key=value", got "${pair}"`);
inputs[pair.slice(0, at)] = pair.slice(at + 1);
}
for (const pair of fl.asList('field')) {
const at = pair.indexOf('=');
if (at < 0)
throw new Error(`expected "key=value", got "${pair}"`);
const value = pair.slice(at + 1);
inputs[pair.slice(0, at)] = value.startsWith('@')
? new TextDecoder().decode(await readCliFile(ctx, value.slice(1), '--field'))
: value;
}
return inputs;
}
export async function workflowRunCmd(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const id = texts[0] ?? '';
if (id === '')
throw new Error('a workflow ID, name, or filename is required');
const transport = accessor.transport;
const ref = repoFor(accessor, fl);
let branch = fl.asNonEmpty('ref') ?? accessor.config.branch;
if (branch === undefined || branch === '') {
const repository = (await viewRepo(transport, ref));
branch = typeof repository.default_branch === 'string' ? repository.default_branch : undefined;
}
if (branch === undefined || branch === '')
throw new Error('a workflow ref is required');
await dispatchWorkflow(transport, ref, id, { ref: branch, inputs: await workflowInputs(ctx, fl) });
return textOut('');
}
//# sourceMappingURL=actions.js.map