mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
implement six-skill gstack 2 runtime
This commit is contained in:
@@ -0,0 +1,970 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||
import { projectPaths } from "./paths.js";
|
||||
import { discoverProjectIdentity } from "./identity.js";
|
||||
import { RUNTIME_SCHEMA_VERSION } from "./migrations.js";
|
||||
|
||||
const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"];
|
||||
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
|
||||
const WORKFLOW_DEPTHS = new Set(["quick", "standard", "deep"]);
|
||||
const EVIDENCE_FRESHNESS = new Set(["unknown", "fresh", "stale"]);
|
||||
const RUN_STATUSES = new Set(["running", "completed"]);
|
||||
const EFFECT_STATUSES = new Set(["ready", "in_progress", "uncertain", "completed"]);
|
||||
const MUTATION_AUTHORITIES = new Set([
|
||||
"source-defined",
|
||||
"report-only",
|
||||
"plan-only",
|
||||
"design-doc-only",
|
||||
"spec-only",
|
||||
"spec-and-issue",
|
||||
"design-artifacts",
|
||||
"fix-safe",
|
||||
"fix-safe-after-root-cause",
|
||||
"investigate-only",
|
||||
"code-generation",
|
||||
"commit-push-pr",
|
||||
"merge-deploy",
|
||||
"deploy",
|
||||
"docs-only",
|
||||
"profile-only",
|
||||
"configuration",
|
||||
"installation",
|
||||
"safety-policy",
|
||||
"state-only",
|
||||
"state-dependent",
|
||||
"approval-required",
|
||||
"none",
|
||||
]);
|
||||
const EXTERNAL_EFFECT_AUTHORITIES = new Set([
|
||||
// source-defined keeps state written by the pre-metadata GStack 2 runtime
|
||||
// resumable; new dispatchers persist their exact authority.
|
||||
"source-defined",
|
||||
"spec-and-issue",
|
||||
"commit-push-pr",
|
||||
"merge-deploy",
|
||||
"deploy",
|
||||
"state-dependent",
|
||||
"configuration",
|
||||
"installation",
|
||||
]);
|
||||
const WORKFLOW_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
"currentPlanPointer",
|
||||
"originalGoal",
|
||||
"detourStack",
|
||||
"currentWorkflowStage",
|
||||
"selectedDepth",
|
||||
"mutationAuthority",
|
||||
"activeModules",
|
||||
"evidenceFreshness",
|
||||
"evidenceProvenance",
|
||||
"pendingApprovalGates",
|
||||
]);
|
||||
const WORKFLOW_TRANSITION_KEYS = new Set([
|
||||
"currentPlanPointer",
|
||||
"currentWorkflowStage",
|
||||
"selectedDepth",
|
||||
"mutationAuthority",
|
||||
"activeModules",
|
||||
"pushDetour",
|
||||
"popDetour",
|
||||
"evidenceFreshness",
|
||||
"addEvidenceProvenance",
|
||||
"addApprovalGate",
|
||||
"resolveApprovalGate",
|
||||
]);
|
||||
|
||||
export async function initializeProject(home, identity, options = {}) {
|
||||
const paths = projectPaths(home, identity.projectId);
|
||||
await fs.mkdir(paths.root, { recursive: true, mode: 0o700 });
|
||||
return withLock(paths.lock, async () => {
|
||||
for (const name of PROJECT_DIRECTORIES) {
|
||||
await fs.mkdir(paths[name], { recursive: true, mode: 0o700 });
|
||||
}
|
||||
const now = isoNow(options.now);
|
||||
let state = await readJson(paths.state, null);
|
||||
if (!state) {
|
||||
state = {
|
||||
schemaVersion: RUNTIME_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
project: {
|
||||
id: identity.projectId,
|
||||
repoId: identity.repoId,
|
||||
worktreeId: identity.worktreeId,
|
||||
worktreeRoot: identity.worktreeRoot,
|
||||
repoCommonDir: identity.repoCommonDir,
|
||||
isGit: identity.isGit,
|
||||
},
|
||||
activeRunId: null,
|
||||
currentPlan: null,
|
||||
runs: Object.create(null),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
} else {
|
||||
assertSupportedState(state, paths.state);
|
||||
// A registered worktree can move. Its ID intentionally changes when it
|
||||
// does; within an existing project, keep display paths fresh.
|
||||
state.project = { ...state.project, ...identity, id: identity.projectId };
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
}
|
||||
await ensureJsonl(paths.timeline);
|
||||
await ensureJsonl(paths.decisions);
|
||||
return { paths, state };
|
||||
});
|
||||
}
|
||||
|
||||
export async function currentProject(home, cwd = process.cwd(), options = {}) {
|
||||
const identity = await discoverProjectIdentity(cwd, options);
|
||||
return initializeProject(home, identity, options);
|
||||
}
|
||||
|
||||
export async function inspectProject(home, identityOrId) {
|
||||
const id = typeof identityOrId === "string" ? identityOrId : identityOrId.projectId;
|
||||
const paths = projectPaths(home, id);
|
||||
const state = await readJson(paths.state, null);
|
||||
if (!state) {
|
||||
const error = new Error(`No state found for project ${id}`);
|
||||
error.code = "STATE_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
assertSupportedState(state, paths.state);
|
||||
return { paths, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the complete durable reconstruction needed to continue one run.
|
||||
* This function is intentionally read-only: callers must use resumeRun before
|
||||
* changing a non-active run.
|
||||
*/
|
||||
export async function inspectRun(home, projectId, runId) {
|
||||
validateRunId(runId);
|
||||
const { paths, state } = await inspectProject(home, projectId);
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
return {
|
||||
paths,
|
||||
state,
|
||||
run,
|
||||
reconstruction: workflowReconstruction(state, run),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProjectState(home, projectId, mutator, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
return withLock(paths.lock, async () => {
|
||||
const state = await readJson(paths.state);
|
||||
assertSupportedState(state, paths.state);
|
||||
const result = await mutator(state);
|
||||
assertSupportedState(state, paths.state);
|
||||
state.revision = Number(state.revision ?? 0) + 1;
|
||||
state.updatedAt = isoNow(options.now);
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
return { state, result };
|
||||
}, options.lock);
|
||||
}
|
||||
|
||||
export async function beginRun(home, projectId, command, options = {}) {
|
||||
const runId = options.runId ?? `run_${Date.now().toString(36)}_${randomUUID().slice(0, 12)}`;
|
||||
validateRunId(runId);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
if (Object.hasOwn(state.runs, runId)) {
|
||||
const error = new Error(`Run already exists: ${runId}`);
|
||||
error.code = "RUN_EXISTS";
|
||||
throw error;
|
||||
}
|
||||
const workflow = createWorkflowState(command, options, now);
|
||||
state.runs[runId] = {
|
||||
id: runId,
|
||||
command: String(command ?? "unknown"),
|
||||
status: "running",
|
||||
workflow,
|
||||
effects: Object.create(null),
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
resumeCount: 0,
|
||||
};
|
||||
state.activeRunId = runId;
|
||||
state.currentPlan = currentPlanProjection(runId, workflow, now);
|
||||
return {
|
||||
result: state.runs[runId],
|
||||
event: { type: "run.started", runId, command: state.runs[runId].command, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
export async function resumeRun(home, projectId, runId, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const selected = runId ?? state.activeRunId ?? newestIncompleteRun(state);
|
||||
if (selected) validateRunId(selected);
|
||||
const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null;
|
||||
if (!run) {
|
||||
const error = new Error(selected ? `Run not found: ${selected}` : "No resumable run found");
|
||||
error.code = "RUN_NOT_FOUND";
|
||||
throw error;
|
||||
}
|
||||
if (run.status === "completed") {
|
||||
const error = new Error(`Run is already complete: ${run.id}`);
|
||||
error.code = "RUN_COMPLETED";
|
||||
throw error;
|
||||
}
|
||||
for (const effect of Object.values(run.effects ?? {})) {
|
||||
if (effect.status === "in_progress") {
|
||||
effect.status = "uncertain";
|
||||
effect.uncertainAt = now;
|
||||
effect.reason = "runtime stopped after effect was claimed; reconcile before retrying";
|
||||
}
|
||||
}
|
||||
run.status = "running";
|
||||
run.resumeCount = Number(run.resumeCount ?? 0) + 1;
|
||||
run.resumedAt = now;
|
||||
run.updatedAt = now;
|
||||
state.activeRunId = run.id;
|
||||
state.currentPlan = currentPlanProjection(run.id, run.workflow, now);
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.resumed", runId: run.id, resumeCount: run.resumeCount, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
export async function completeRun(home, projectId, runId, options = {}) {
|
||||
validateRunId(runId);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
const unresolved = Object.values(run.effects ?? {}).filter((effect) =>
|
||||
["ready", "in_progress", "uncertain"].includes(effect.status));
|
||||
if (unresolved.length && !options.allowUncertain) {
|
||||
throw codedError("EFFECTS_UNCERTAIN", "Run has unresolved external effects");
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_GATES_PENDING", "Run has pending approval gates");
|
||||
}
|
||||
run.status = "completed";
|
||||
run.completedAt = now;
|
||||
run.updatedAt = now;
|
||||
if (state.activeRunId === runId) {
|
||||
state.activeRunId = null;
|
||||
state.currentPlan = null;
|
||||
}
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.completed", runId, at: now },
|
||||
};
|
||||
}, options);
|
||||
return { state, run: result, reconstruction: workflowReconstruction(state, result) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an explicit workflow transition under the same project lock used for
|
||||
* external-effect claims. The original goal is deliberately not patchable.
|
||||
*/
|
||||
export async function updateRunWorkflow(home, projectId, runId, transition, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateWorkflowTransition(transition);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before updating: ${runId}`);
|
||||
}
|
||||
|
||||
const changes = applyWorkflowTransition(run.workflow, transition, now);
|
||||
run.updatedAt = now;
|
||||
state.currentPlan = currentPlanProjection(runId, run.workflow, now);
|
||||
return {
|
||||
result: run,
|
||||
event: { type: "run.workflow_updated", runId, changes, at: now },
|
||||
};
|
||||
}, options);
|
||||
return {
|
||||
state,
|
||||
run: result,
|
||||
reconstruction: workflowReconstruction(state, result),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an external side effect at most once from gstack's perspective.
|
||||
*
|
||||
* A durable claim is written before execute() is called. If the process dies
|
||||
* after the external system accepts the action, resume marks that claim
|
||||
* uncertain and will not call execute() again. The stable idempotencyKey should
|
||||
* also be passed to APIs that support native idempotency.
|
||||
*/
|
||||
export async function runExternalEffect(home, projectId, runId, effectKey, execute, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
const claimed = await updateWithEvent(paths, async (state) => {
|
||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
||||
if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
||||
if (state.activeRunId !== runId) {
|
||||
throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before an external effect: ${runId}`);
|
||||
}
|
||||
if (run.workflow.pendingApprovalGates.length) {
|
||||
throw codedError("APPROVAL_REQUIRED", "Resolve pending approval gates before external effects");
|
||||
}
|
||||
if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) {
|
||||
throw codedError(
|
||||
"MUTATION_NOT_AUTHORIZED",
|
||||
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
|
||||
);
|
||||
}
|
||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||
const existing = Object.hasOwn(run.effects, effectKey) ? run.effects[effectKey] : null;
|
||||
if (existing?.status === "completed") {
|
||||
return { result: { action: "completed", effect: existing } };
|
||||
}
|
||||
if (existing && ["in_progress", "uncertain"].includes(existing.status)) {
|
||||
existing.status = "uncertain";
|
||||
existing.uncertainAt ??= now;
|
||||
return { result: { action: "uncertain", effect: existing } };
|
||||
}
|
||||
const effect = {
|
||||
key: effectKey,
|
||||
status: "in_progress",
|
||||
idempotencyKey: existing?.idempotencyKey ?? stableIdempotencyKey(projectId, runId, effectKey),
|
||||
attempts: Number(existing?.attempts ?? 0) + 1,
|
||||
claimedAt: now,
|
||||
};
|
||||
run.effects[effectKey] = effect;
|
||||
run.updatedAt = now;
|
||||
return {
|
||||
result: { action: "execute", effect },
|
||||
event: { type: "effect.claimed", runId, effectKey, idempotencyKey: effect.idempotencyKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
|
||||
if (claimed.result.action === "completed") {
|
||||
return { status: "completed", repeated: true, result: claimed.result.effect.result, idempotencyKey: claimed.result.effect.idempotencyKey };
|
||||
}
|
||||
if (claimed.result.action === "uncertain") {
|
||||
return {
|
||||
status: "uncertain",
|
||||
repeated: false,
|
||||
idempotencyKey: claimed.result.effect.idempotencyKey,
|
||||
reason: "Effect was already claimed; reconcile it explicitly before retrying",
|
||||
};
|
||||
}
|
||||
|
||||
const effect = claimed.result.effect;
|
||||
try {
|
||||
const result = await execute({ idempotencyKey: effect.idempotencyKey, effectKey, runId });
|
||||
await completeExternalEffect(home, projectId, runId, effectKey, result, options);
|
||||
return { status: "completed", repeated: false, result, idempotencyKey: effect.idempotencyKey };
|
||||
} catch (cause) {
|
||||
await markEffectUncertain(home, projectId, runId, effectKey, cause, options).catch(() => {});
|
||||
const error = new Error(`External effect ${effectKey} may have occurred; refusing automatic retry`, { cause });
|
||||
error.code = "EXTERNAL_EFFECT_UNCERTAIN";
|
||||
error.idempotencyKey = effect.idempotencyKey;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeExternalEffect(home, projectId, runId, effectKey, result, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "in_progress") {
|
||||
throw codedError("EFFECT_NOT_IN_PROGRESS", `Effect is not in progress: ${effectKey}`);
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
effect.result = jsonSafe(result);
|
||||
delete effect.reason;
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.completed", runId, effectKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function markEffectNotApplied(home, projectId, runId, effectKey, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
const paths = projectPaths(home, projectId);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as not applied: ${effectKey}`);
|
||||
}
|
||||
effect.status = "ready";
|
||||
effect.reconciledAt = isoNow(options.now);
|
||||
effect.reason = "caller confirmed the external action did not occur";
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.reconciled_not_applied", runId, effectKey, at: effect.reconciledAt },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function markEffectApplied(home, projectId, runId, effectKey, evidence, options = {}) {
|
||||
validateRunId(runId);
|
||||
validateEffectKey(effectKey);
|
||||
if (typeof evidence !== "string" || !evidence.trim() || evidence.length > 500 || /[\r\n\0]/.test(evidence)) {
|
||||
throw new TypeError("A compact, single-line external evidence reference is required");
|
||||
}
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
if (effect.status !== "uncertain") {
|
||||
throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as applied: ${effectKey}`);
|
||||
}
|
||||
effect.status = "completed";
|
||||
effect.completedAt = now;
|
||||
effect.reconciledAt = now;
|
||||
effect.result = { reconciled: true, evidence: evidence.trim() };
|
||||
effect.reason = "external inspection confirmed the action occurred";
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.reconciled_applied", runId, effectKey, evidence: evidence.trim(), at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
export async function appendDecision(home, projectId, decision, options = {}) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
return withLock(paths.lock, async () => {
|
||||
const record = { ...jsonSafe(decision), id: randomUUID(), at: isoNow(options.now) };
|
||||
await appendJsonLine(paths.decisions, record, { mode: 0o600 });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
async function markEffectUncertain(home, projectId, runId, effectKey, cause, options) {
|
||||
const paths = projectPaths(home, projectId);
|
||||
const now = isoNow(options.now);
|
||||
return updateWithEvent(paths, async (state) => {
|
||||
const effect = ownedEffect(state, runId, effectKey);
|
||||
if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`);
|
||||
effect.status = "uncertain";
|
||||
effect.uncertainAt = now;
|
||||
effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500);
|
||||
return {
|
||||
result: effect,
|
||||
event: { type: "effect.uncertain", runId, effectKey, at: now },
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function updateWithEvent(paths, mutator, options = {}) {
|
||||
return withLock(paths.lock, async () => {
|
||||
const state = await readJson(paths.state);
|
||||
assertSupportedState(state, paths.state);
|
||||
const mutation = await mutator(state);
|
||||
assertSupportedState(state, paths.state);
|
||||
state.revision = Number(state.revision ?? 0) + 1;
|
||||
state.updatedAt = isoNow(options.now);
|
||||
await atomicWriteJson(paths.state, state, { mode: 0o600 });
|
||||
if (mutation.event) await appendJsonLine(paths.timeline, mutation.event, { mode: 0o600 });
|
||||
return { state, result: mutation.result };
|
||||
}, options.lock);
|
||||
}
|
||||
|
||||
async function ensureJsonl(file) {
|
||||
try {
|
||||
const handle = await fs.open(file, "wx", 0o600);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
await fs.chmod(file, 0o600);
|
||||
}
|
||||
|
||||
function newestIncompleteRun(state) {
|
||||
return Object.values(state.runs ?? {})
|
||||
.filter((run) => run.status !== "completed")
|
||||
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function assertSupportedState(state, file) {
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) throw new Error(`Invalid state file: ${file}`);
|
||||
if (!Number.isInteger(state.schemaVersion) || state.schemaVersion < 1) {
|
||||
throw new Error(`Invalid state schema in ${file}`);
|
||||
}
|
||||
if (state.schemaVersion > RUNTIME_SCHEMA_VERSION) {
|
||||
throw codedError("STATE_NEWER_THAN_RUNTIME", `State schema is newer than this runtime: ${file}`);
|
||||
}
|
||||
if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`);
|
||||
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
|
||||
for (const [runId, run] of Object.entries(state.runs)) {
|
||||
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error(`Invalid run record in ${file}`);
|
||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||
if (run.id !== runId) throw new Error(`Run key/id mismatch in ${file}`);
|
||||
if (!RUN_STATUSES.has(run.status)) throw new Error(`Invalid run status in ${file}`);
|
||||
validateCompactLine(run.command, "run command", 128);
|
||||
validateIso(run.startedAt, "run start timestamp");
|
||||
validateIso(run.updatedAt, "run update timestamp");
|
||||
if (!Number.isInteger(run.resumeCount) || run.resumeCount < 0) throw new Error(`Invalid resume count in ${file}`);
|
||||
if (run.status === "completed") validateIso(run.completedAt, "run completion timestamp");
|
||||
if (run.workflow == null) {
|
||||
// Runs written by the first GStack 2 runtime are upgraded in memory and
|
||||
// become durable on the next locked mutation. This preserves resume
|
||||
// without treating missing metadata as trustworthy caller input.
|
||||
run.workflow = createWorkflowState(run.command, {
|
||||
currentWorkflowStage: run.status === "completed" ? "completed" : "initialized",
|
||||
}, validIsoOrNow(run.updatedAt));
|
||||
}
|
||||
validateWorkflowState(run.workflow, `run ${runId} in ${file}`);
|
||||
for (const [effectKey, effect] of Object.entries(run.effects)) {
|
||||
if (!effect || typeof effect !== "object" || effect.key !== effectKey) {
|
||||
throw new Error(`Effect key/id mismatch in ${file}`);
|
||||
}
|
||||
if (!EFFECT_STATUSES.has(effect.status)) throw new Error(`Invalid effect status in ${file}`);
|
||||
if (typeof effect.idempotencyKey !== "string" || !/^gstack_[0-9a-f]{64}$/.test(effect.idempotencyKey)) {
|
||||
throw new Error(`Invalid effect idempotency key in ${file}`);
|
||||
}
|
||||
if (!Number.isInteger(effect.attempts) || effect.attempts < 1) {
|
||||
throw new Error(`Invalid effect attempt count in ${file}`);
|
||||
}
|
||||
validateIso(effect.claimedAt, "effect claim timestamp");
|
||||
if (effect.status === "completed") validateIso(effect.completedAt, "effect completion timestamp");
|
||||
if (effect.status === "uncertain") validateIso(effect.uncertainAt, "effect uncertainty timestamp");
|
||||
if (effect.status === "ready") validateIso(effect.reconciledAt, "effect reconciliation timestamp");
|
||||
}
|
||||
}
|
||||
if (state.activeRunId != null) {
|
||||
validateRunId(state.activeRunId);
|
||||
if (!Object.hasOwn(state.runs, state.activeRunId)) {
|
||||
throw new Error(`Active run does not exist in ${file}`);
|
||||
}
|
||||
if (state.runs[state.activeRunId].status !== "running") {
|
||||
throw new Error(`Active run is not running in ${file}`);
|
||||
}
|
||||
}
|
||||
if (state.currentPlan === undefined) {
|
||||
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
|
||||
state.currentPlan = active
|
||||
? currentPlanProjection(active.id, active.workflow, validIsoOrNow(active.updatedAt))
|
||||
: null;
|
||||
}
|
||||
validateCurrentPlanProjection(state, file);
|
||||
}
|
||||
|
||||
function createWorkflowState(command, options, now) {
|
||||
const requestedFreshness = typeof options.evidenceFreshness === "string"
|
||||
? options.evidenceFreshness
|
||||
: options.evidenceFreshness?.status ?? "unknown";
|
||||
const workflow = {
|
||||
schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
|
||||
currentPlanPointer: options.currentPlanPointer == null ? null : options.currentPlanPointer.trim(),
|
||||
originalGoal: options.originalGoal ?? String(command ?? "unknown"),
|
||||
detourStack: options.detourStack ?? [],
|
||||
currentWorkflowStage: options.currentWorkflowStage ?? "initialized",
|
||||
selectedDepth: options.selectedDepth ?? "standard",
|
||||
mutationAuthority: options.mutationAuthority ?? "source-defined",
|
||||
activeModules: options.activeModules ?? [],
|
||||
evidenceFreshness: {
|
||||
status: requestedFreshness,
|
||||
assessedAt: requestedFreshness === "unknown" ? null : now,
|
||||
},
|
||||
evidenceProvenance: options.evidenceProvenance ?? [],
|
||||
pendingApprovalGates: options.pendingApprovalGates ?? [],
|
||||
};
|
||||
// Normalize generated timestamps for initial metadata before validation.
|
||||
workflow.detourStack = workflow.detourStack.map((detour) => ({
|
||||
...detour,
|
||||
fromStage: detour?.fromStage ?? workflow.currentWorkflowStage,
|
||||
enteredAt: now,
|
||||
}));
|
||||
workflow.evidenceProvenance = workflow.evidenceProvenance.map((entry) => ({
|
||||
...entry,
|
||||
capturedAt: entry?.capturedAt ?? now,
|
||||
recordedAt: now,
|
||||
}));
|
||||
workflow.pendingApprovalGates = workflow.pendingApprovalGates.map((gate) => ({
|
||||
...gate,
|
||||
requestedAt: now,
|
||||
}));
|
||||
validateWorkflowState(workflow, "new workflow");
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function validateWorkflowState(workflow, label) {
|
||||
assertPlainRecord(workflow, label, WORKFLOW_KEYS);
|
||||
if (workflow.schemaVersion !== WORKFLOW_STATE_SCHEMA_VERSION) {
|
||||
throw codedError("WORKFLOW_SCHEMA_UNSUPPORTED", `Unsupported workflow schema in ${label}`);
|
||||
}
|
||||
validateOptionalPointer(workflow.currentPlanPointer);
|
||||
validateGoal(workflow.originalGoal, "original goal");
|
||||
if (!Array.isArray(workflow.detourStack) || workflow.detourStack.length > 64) {
|
||||
throw new TypeError(`Invalid detour stack in ${label}`);
|
||||
}
|
||||
for (const detour of workflow.detourStack) validateDetour(detour);
|
||||
validateWorkflowToken(workflow.currentWorkflowStage, "workflow stage");
|
||||
if (!WORKFLOW_DEPTHS.has(workflow.selectedDepth)) throw new TypeError("Invalid selected depth");
|
||||
validateMutationAuthority(workflow.mutationAuthority);
|
||||
validateModuleList(workflow.activeModules);
|
||||
validateEvidenceFreshness(workflow.evidenceFreshness);
|
||||
if (!Array.isArray(workflow.evidenceProvenance) || workflow.evidenceProvenance.length > 512) {
|
||||
throw new TypeError(`Invalid evidence provenance in ${label}`);
|
||||
}
|
||||
for (const entry of workflow.evidenceProvenance) validateEvidenceProvenance(entry);
|
||||
if (workflow.evidenceFreshness.status === "fresh" && workflow.evidenceProvenance.length === 0) {
|
||||
throw new TypeError("Fresh evidence requires provenance");
|
||||
}
|
||||
if (!Array.isArray(workflow.pendingApprovalGates) || workflow.pendingApprovalGates.length > 64) {
|
||||
throw new TypeError(`Invalid pending approval gates in ${label}`);
|
||||
}
|
||||
const gateIds = new Set();
|
||||
for (const gate of workflow.pendingApprovalGates) {
|
||||
validateApprovalGate(gate);
|
||||
if (gateIds.has(gate.id)) throw new TypeError(`Duplicate approval gate: ${gate.id}`);
|
||||
gateIds.add(gate.id);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowTransition(transition) {
|
||||
assertPlainRecord(transition, "workflow transition", WORKFLOW_TRANSITION_KEYS);
|
||||
if (Object.keys(transition).length === 0) throw new TypeError("Workflow transition cannot be empty");
|
||||
if (Object.hasOwn(transition, "currentPlanPointer")) validateOptionalPointer(transition.currentPlanPointer);
|
||||
if (Object.hasOwn(transition, "currentWorkflowStage")) {
|
||||
validateWorkflowToken(transition.currentWorkflowStage, "workflow stage");
|
||||
}
|
||||
if (Object.hasOwn(transition, "selectedDepth") && !WORKFLOW_DEPTHS.has(transition.selectedDepth)) {
|
||||
throw new TypeError("Invalid selected depth");
|
||||
}
|
||||
if (Object.hasOwn(transition, "mutationAuthority")) {
|
||||
validateMutationAuthority(transition.mutationAuthority);
|
||||
}
|
||||
if (Object.hasOwn(transition, "activeModules")) validateModuleList(transition.activeModules);
|
||||
if (Object.hasOwn(transition, "pushDetour")) validateGoal(transition.pushDetour, "detour goal");
|
||||
if (Object.hasOwn(transition, "popDetour") && transition.popDetour !== true) {
|
||||
throw new TypeError("popDetour must be true");
|
||||
}
|
||||
if (Object.hasOwn(transition, "pushDetour") && Object.hasOwn(transition, "popDetour")) {
|
||||
throw new TypeError("Cannot push and pop a detour in one transition");
|
||||
}
|
||||
if (Object.hasOwn(transition, "evidenceFreshness") && !EVIDENCE_FRESHNESS.has(transition.evidenceFreshness)) {
|
||||
throw new TypeError("Invalid evidence freshness");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
|
||||
validateEvidenceProvenanceInput(transition.addEvidenceProvenance);
|
||||
}
|
||||
if (Object.hasOwn(transition, "addApprovalGate")) validateApprovalGateInput(transition.addApprovalGate);
|
||||
if (Object.hasOwn(transition, "resolveApprovalGate")) validateStateKey(transition.resolveApprovalGate, "approval gate id");
|
||||
if (Object.hasOwn(transition, "addApprovalGate") && Object.hasOwn(transition, "resolveApprovalGate") &&
|
||||
transition.addApprovalGate.id === transition.resolveApprovalGate) {
|
||||
throw new TypeError("Cannot add and resolve the same approval gate in one transition");
|
||||
}
|
||||
}
|
||||
|
||||
function applyWorkflowTransition(workflow, transition, now) {
|
||||
const changes = [];
|
||||
const previousStage = workflow.currentWorkflowStage;
|
||||
if (Object.hasOwn(transition, "currentPlanPointer")) {
|
||||
workflow.currentPlanPointer = transition.currentPlanPointer == null ? null : transition.currentPlanPointer.trim();
|
||||
changes.push("currentPlanPointer");
|
||||
}
|
||||
if (Object.hasOwn(transition, "currentWorkflowStage")) {
|
||||
workflow.currentWorkflowStage = transition.currentWorkflowStage;
|
||||
changes.push("currentWorkflowStage");
|
||||
}
|
||||
if (Object.hasOwn(transition, "selectedDepth")) {
|
||||
workflow.selectedDepth = transition.selectedDepth;
|
||||
changes.push("selectedDepth");
|
||||
}
|
||||
if (Object.hasOwn(transition, "mutationAuthority")) {
|
||||
workflow.mutationAuthority = transition.mutationAuthority;
|
||||
changes.push("mutationAuthority");
|
||||
}
|
||||
if (Object.hasOwn(transition, "activeModules")) {
|
||||
workflow.activeModules = [...new Set(transition.activeModules)];
|
||||
changes.push("activeModules");
|
||||
}
|
||||
if (Object.hasOwn(transition, "pushDetour")) {
|
||||
workflow.detourStack.push({
|
||||
goal: transition.pushDetour.trim(),
|
||||
fromStage: previousStage,
|
||||
enteredAt: now,
|
||||
});
|
||||
changes.push("detourStack.push");
|
||||
}
|
||||
if (transition.popDetour === true) {
|
||||
if (workflow.detourStack.length === 0) throw codedError("DETOUR_STACK_EMPTY", "No detour is available to pop");
|
||||
workflow.detourStack.pop();
|
||||
changes.push("detourStack.pop");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addEvidenceProvenance")) {
|
||||
const input = transition.addEvidenceProvenance;
|
||||
workflow.evidenceProvenance.push({
|
||||
source: input.source,
|
||||
reference: input.reference.trim(),
|
||||
capturedAt: input.capturedAt ?? now,
|
||||
recordedAt: now,
|
||||
});
|
||||
if (!Object.hasOwn(transition, "evidenceFreshness")) {
|
||||
workflow.evidenceFreshness = { status: "unknown", assessedAt: null };
|
||||
}
|
||||
changes.push("evidenceProvenance.add");
|
||||
}
|
||||
if (Object.hasOwn(transition, "evidenceFreshness")) {
|
||||
if (transition.evidenceFreshness === "fresh" && workflow.evidenceProvenance.length === 0) {
|
||||
throw new TypeError("Fresh evidence requires provenance");
|
||||
}
|
||||
workflow.evidenceFreshness = { status: transition.evidenceFreshness, assessedAt: now };
|
||||
changes.push("evidenceFreshness");
|
||||
}
|
||||
if (Object.hasOwn(transition, "addApprovalGate")) {
|
||||
const input = transition.addApprovalGate;
|
||||
if (workflow.pendingApprovalGates.some((gate) => gate.id === input.id)) {
|
||||
throw codedError("APPROVAL_GATE_EXISTS", `Approval gate already exists: ${input.id}`);
|
||||
}
|
||||
workflow.pendingApprovalGates.push({
|
||||
id: input.id,
|
||||
summary: input.summary.trim(),
|
||||
requestedAt: now,
|
||||
});
|
||||
changes.push("pendingApprovalGates.add");
|
||||
}
|
||||
if (Object.hasOwn(transition, "resolveApprovalGate")) {
|
||||
const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate);
|
||||
if (index === -1) {
|
||||
throw codedError("APPROVAL_GATE_NOT_FOUND", `Approval gate not found: ${transition.resolveApprovalGate}`);
|
||||
}
|
||||
workflow.pendingApprovalGates.splice(index, 1);
|
||||
changes.push("pendingApprovalGates.resolve");
|
||||
}
|
||||
validateWorkflowState(workflow, "updated workflow");
|
||||
return changes;
|
||||
}
|
||||
|
||||
function workflowReconstruction(state, run) {
|
||||
const currentDetour = run.workflow.detourStack.at(-1);
|
||||
return {
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
isActive: state.activeRunId === run.id,
|
||||
currentPlan: state.activeRunId === run.id ? state.currentPlan : currentPlanProjection(run.id, run.workflow, run.updatedAt),
|
||||
currentPlanPointer: run.workflow.currentPlanPointer,
|
||||
originalGoal: run.workflow.originalGoal,
|
||||
currentGoal: currentDetour?.goal ?? run.workflow.originalGoal,
|
||||
detourStack: run.workflow.detourStack,
|
||||
currentWorkflowStage: run.workflow.currentWorkflowStage,
|
||||
selectedDepth: run.workflow.selectedDepth,
|
||||
mutationAuthority: run.workflow.mutationAuthority,
|
||||
activeModules: run.workflow.activeModules,
|
||||
evidenceFreshness: run.workflow.evidenceFreshness,
|
||||
evidenceProvenance: run.workflow.evidenceProvenance,
|
||||
pendingApprovalGates: run.workflow.pendingApprovalGates,
|
||||
effects: run.effects,
|
||||
};
|
||||
}
|
||||
|
||||
function currentPlanProjection(runId, workflow, at) {
|
||||
return workflow.currentPlanPointer == null
|
||||
? null
|
||||
: { runId, pointer: workflow.currentPlanPointer, updatedAt: validIsoOrNow(at) };
|
||||
}
|
||||
|
||||
function validateCurrentPlanProjection(state, file) {
|
||||
if (state.currentPlan == null) {
|
||||
const active = state.activeRunId == null ? null : state.runs[state.activeRunId];
|
||||
if (active?.workflow.currentPlanPointer != null) {
|
||||
throw new Error(`Current plan projection is missing in ${file}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertPlainRecord(state.currentPlan, `current plan in ${file}`, new Set(["runId", "pointer", "updatedAt"]));
|
||||
validateRunId(state.currentPlan.runId);
|
||||
validateOptionalPointer(state.currentPlan.pointer, false);
|
||||
validateIso(state.currentPlan.updatedAt, "current plan timestamp");
|
||||
if (state.activeRunId !== state.currentPlan.runId || !Object.hasOwn(state.runs, state.currentPlan.runId)) {
|
||||
throw new Error(`Current plan is not owned by the active run in ${file}`);
|
||||
}
|
||||
if (state.runs[state.currentPlan.runId].workflow.currentPlanPointer !== state.currentPlan.pointer) {
|
||||
throw new Error(`Current plan projection is inconsistent in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDetour(detour) {
|
||||
assertPlainRecord(detour, "detour", new Set(["goal", "fromStage", "enteredAt"]));
|
||||
validateGoal(detour.goal, "detour goal");
|
||||
validateWorkflowToken(detour.fromStage, "detour source stage");
|
||||
validateIso(detour.enteredAt, "detour timestamp");
|
||||
}
|
||||
|
||||
function validateEvidenceFreshness(value) {
|
||||
assertPlainRecord(value, "evidence freshness", new Set(["status", "assessedAt"]));
|
||||
if (!EVIDENCE_FRESHNESS.has(value.status)) throw new TypeError("Invalid evidence freshness");
|
||||
if (value.assessedAt != null) validateIso(value.assessedAt, "evidence assessment timestamp");
|
||||
if (value.status !== "unknown" && value.assessedAt == null) {
|
||||
throw new TypeError("Assessed evidence freshness requires a timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceProvenanceInput(entry) {
|
||||
assertPlainRecord(entry, "evidence provenance input", new Set(["source", "reference", "capturedAt"]));
|
||||
validateWorkflowToken(entry.source, "evidence source");
|
||||
validateCompactLine(entry.reference, "evidence reference", 2_048);
|
||||
if (entry.capturedAt != null) validateIso(entry.capturedAt, "evidence capture timestamp");
|
||||
}
|
||||
|
||||
function validateEvidenceProvenance(entry) {
|
||||
assertPlainRecord(entry, "evidence provenance", new Set(["source", "reference", "capturedAt", "recordedAt"]));
|
||||
validateWorkflowToken(entry.source, "evidence source");
|
||||
validateCompactLine(entry.reference, "evidence reference", 2_048);
|
||||
validateIso(entry.capturedAt, "evidence capture timestamp");
|
||||
validateIso(entry.recordedAt, "evidence record timestamp");
|
||||
}
|
||||
|
||||
function validateApprovalGateInput(gate) {
|
||||
assertPlainRecord(gate, "approval gate input", new Set(["id", "summary"]));
|
||||
validateStateKey(gate.id, "approval gate id");
|
||||
validateGoal(gate.summary, "approval gate summary", 2_048);
|
||||
}
|
||||
|
||||
function validateApprovalGate(gate) {
|
||||
assertPlainRecord(gate, "approval gate", new Set(["id", "summary", "requestedAt"]));
|
||||
validateStateKey(gate.id, "approval gate id");
|
||||
validateGoal(gate.summary, "approval gate summary", 2_048);
|
||||
validateIso(gate.requestedAt, "approval gate timestamp");
|
||||
}
|
||||
|
||||
function validateModuleList(modules) {
|
||||
if (!Array.isArray(modules) || modules.length > 64) throw new TypeError("Invalid active modules");
|
||||
const seen = new Set();
|
||||
for (const moduleName of modules) {
|
||||
validateStateKey(moduleName, "active module");
|
||||
if (seen.has(moduleName)) throw new TypeError(`Duplicate active module: ${moduleName}`);
|
||||
seen.add(moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalPointer(value, nullable = true) {
|
||||
if (nullable && value == null) return;
|
||||
validateCompactLine(value, "current plan pointer", 2_048);
|
||||
}
|
||||
|
||||
function validateWorkflowToken(value, label) {
|
||||
validateStateKey(value, label);
|
||||
}
|
||||
|
||||
function validateMutationAuthority(value) {
|
||||
validateWorkflowToken(value, "mutation authority");
|
||||
if (!MUTATION_AUTHORITIES.has(value)) throw new TypeError("Unsupported mutation authority");
|
||||
}
|
||||
|
||||
function validateGoal(value, label, max = 20_000) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCompactLine(value, label, max) {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > max || /[\r\n\0]/.test(value)) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateIso(value, label) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
|
||||
Number.isNaN(Date.parse(value))) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validIsoOrNow(value) {
|
||||
try {
|
||||
validateIso(value, "timestamp");
|
||||
return value;
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlainRecord(value, label, allowedKeys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (["__proto__", "prototype", "constructor"].includes(key) || !allowedKeys.has(key)) {
|
||||
throw new TypeError(`Unknown ${label} field: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(value, validateKey, label) {
|
||||
if (value == null) return Object.create(null);
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new TypeError(`Invalid ${label} record`);
|
||||
const normalized = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
validateKey(key);
|
||||
normalized[key] = child;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ownedEffect(state, runId, effectKey) {
|
||||
const run = Object.hasOwn(state.runs ?? {}, runId) ? state.runs[runId] : null;
|
||||
if (!run || !Object.hasOwn(run.effects ?? {}, effectKey)) return null;
|
||||
return run.effects[effectKey];
|
||||
}
|
||||
|
||||
function validateRunId(value) {
|
||||
validateStateKey(value, "run id");
|
||||
}
|
||||
|
||||
function validateEffectKey(value) {
|
||||
validateStateKey(value, "external effect key");
|
||||
}
|
||||
|
||||
function validateStateKey(value, label) {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value) ||
|
||||
["__proto__", "prototype", "constructor"].includes(value)) {
|
||||
throw new TypeError(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stableIdempotencyKey(projectId, runId, effectKey) {
|
||||
const digest = createHash("sha256")
|
||||
.update(String(projectId)).update("\0")
|
||||
.update(runId).update("\0")
|
||||
.update(effectKey)
|
||||
.digest("hex");
|
||||
return `gstack_${digest}`;
|
||||
}
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
function jsonSafe(value) {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function codedError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user