@staix/agent-hub 0.12.43 → 0.12.45
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/CHANGELOG.md +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/sdlc.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { planConformanceBlockers, type PlanConformance } from "./plan-conformance.ts";
|
|
2
|
+
import { DEFAULT_MAX_NITS, findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
1
3
|
import type { CostPlan } from "./cost-governance.ts";
|
|
2
4
|
import { criteriaReceipt, type CriteriaReceipt, type CriteriaView, type LegacyPhaseReceipt } from "./workflow.ts";
|
|
3
5
|
import { constants, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
@@ -29,7 +31,7 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
29
31
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
30
32
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
31
33
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
32
|
-
export interface SdlcConfig { workflow?: "gsd-lite"; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
34
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; mode: SdlcMode; requiredChecks: string[]; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
33
35
|
export interface PlanReviewReceipt {
|
|
34
36
|
planner: string;
|
|
35
37
|
planningRevision: number;
|
|
@@ -63,6 +65,8 @@ export interface PrEvidence {
|
|
|
63
65
|
pendingReviews: number | null;
|
|
64
66
|
paths: string[];
|
|
65
67
|
pathsComplete?: boolean;
|
|
68
|
+
/** All affected path identities are represented; rename/copy destinations alone cannot certify S2. */
|
|
69
|
+
typesComplete?: boolean;
|
|
66
70
|
draft?: boolean;
|
|
67
71
|
base?: string;
|
|
68
72
|
baseHead?: string;
|
|
@@ -125,7 +129,10 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
125
129
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
126
130
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
127
131
|
const raw = value as Record<string, unknown>;
|
|
128
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
132
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
133
|
+
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
134
|
+
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
135
|
+
const findingConfig = raw.max_nits === undefined ? {} : { maxNits };
|
|
129
136
|
const mode = raw.mode ?? "off";
|
|
130
137
|
if (mode !== "off" && mode !== "advise" && mode !== "enforce") throw new Error("sdlc.mode: expected off, advise or enforce");
|
|
131
138
|
// ponytail: the #481 S8 window is a fixed version; delete `workflow`, the phase aliases and their tests once it passes.
|
|
@@ -140,7 +147,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
140
147
|
if (raw.definitions !== undefined) {
|
|
141
148
|
const custom = definitions(raw.definitions, raw.stages, phaseAlias);
|
|
142
149
|
if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
|
|
143
|
-
return { mode, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
150
|
+
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
144
151
|
}
|
|
145
152
|
// Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
|
|
146
153
|
const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
|
|
@@ -148,7 +155,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
148
155
|
// An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
|
|
149
156
|
const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
|
|
150
157
|
if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
|
|
151
|
-
return { mode, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
158
|
+
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
@@ -216,7 +223,9 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
216
223
|
return { name, state } as PrEvidence["checks"][number];
|
|
217
224
|
});
|
|
218
225
|
const paths = p.files.map((f: Record<string, unknown>) => { if (typeof f.path !== "string" || !f.path || f.path.length > 1000) throw new Error("PR readback: invalid file identity"); return f.path; });
|
|
219
|
-
|
|
226
|
+
const completeTypes = p.files.every((file: Record<string, unknown>) => typeof file.changeType === "string" && ["ADDED", "DELETED", "MODIFIED", "CHANGED"].includes(file.changeType));
|
|
227
|
+
// Rename/copy metadata exposes only its destination, never a complete inventory of affected identities.
|
|
228
|
+
return { head: p.headRefOid, state: p.state, checks, paths, pathsComplete: Number.isSafeInteger(p.changedFiles) && p.changedFiles >= 0 && p.changedFiles === paths.length, typesComplete: completeTypes, ...(typeof p.isDraft === "boolean" ? { draft: p.isDraft } : {}), ...(typeof p.baseRefName === "string" && p.baseRefName && p.baseRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.baseRefName) ? { base: p.baseRefName } : {}), ...(sha(p.baseRefOid) ? { baseHead: p.baseRefOid } : {}),
|
|
220
229
|
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
221
230
|
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
222
231
|
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
@@ -239,12 +248,16 @@ export const SPEC_STATE_TEXT: Record<Exclude<SpecState, "current">, string> = {
|
|
|
239
248
|
changed: "spec changed after acceptance: the person re-accepts with ahub sdlc accept <task> --issue <n>",
|
|
240
249
|
unknown: "spec edit time unknown: the issue could not be read",
|
|
241
250
|
};
|
|
242
|
-
export interface SdlcStatus { spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
251
|
+
export interface SdlcStatus { planConformance?: PlanConformance & { accepted?: boolean; earlierPushes?: "unknown"; revisionsAfterFirstObservedPush?: number }; findings?: ReviewFindingTally; spec?: { issue: number; state: SpecState }; playbook?: string; criteria?: CriteriaView; mode: SdlcMode; stage: SdlcStage; definition?: StageDefinition; entry?: string[]; exit?: string[]; blockers: string[]; ready: boolean; actor: string; next: string; }
|
|
243
252
|
/** The acceptance-criteria checklist (#481 S8, Compliance pass) counts once recorded, and on every task under the retired gsd-lite setting. Off keeps #257's output. */
|
|
244
253
|
export const criteriaRequired = (config: SdlcConfig, receipt: SdlcReceipt | undefined): boolean => config.workflow === "gsd-lite" || (config.mode !== "off" && !!checklistOf(receipt));
|
|
245
|
-
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit): SdlcStatus {
|
|
254
|
+
export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined, evidence?: PrEvidence, criteria?: CriteriaView, specEdit?: SpecEdit, sets: readonly ReviewFindingSet[] = [], commit?: string, plan?: SdlcStatus["planConformance"]): SdlcStatus {
|
|
246
255
|
const stage = receipt?.stage ?? config.stages[0] ?? "spec", blockers: string[] = [];
|
|
247
256
|
const definition = stageDefinition(config, stage);
|
|
257
|
+
const items = findingsForHead(sets, findingHead(receipt, evidence, commit));
|
|
258
|
+
const findings = items.length ? findingTally(items) : undefined;
|
|
259
|
+
if (config.mode === "enforce" && findings?.openImportant) blockers.push("open important review findings: " + findings.openImportant);
|
|
260
|
+
if (config.mode === "enforce") blockers.push(...planConformanceBlockers(plan));
|
|
248
261
|
if (!definition) blockers.push("lifecycle stage definition unknown");
|
|
249
262
|
if (!receipt?.repo || !receipt.issue) blockers.push("issue identity missing");
|
|
250
263
|
if (!receipt?.pr) blockers.push("PR identity missing");
|
|
@@ -270,7 +283,7 @@ export function sdlcStatus(config: SdlcConfig, receipt: SdlcReceipt | undefined,
|
|
|
270
283
|
const shown = criteria && (criteriaRequired(config, receipt) || definition && [...definition.entry_gates, ...definition.exit_gates].includes("criteria"));
|
|
271
284
|
// Shown, not counted as a readiness blocker: the spec gate is build entry (#481 S1).
|
|
272
285
|
const spec = config.mode !== "off" && receipt?.issue ? { spec: { issue: receipt.issue, state: specState(receipt, specEdit) } } : {};
|
|
273
|
-
return { ...spec, ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
286
|
+
return { ...spec, ...(config.mode !== "off" && plan ? { planConformance: plan } : {}), ...(findings ? { findings } : {}), ...(config.mode !== "off" && definition && playbookStage(definition.base) ? { playbook: playbookStage(definition.base) } : {}), ...(shown ? { criteria } : {}), ...(custom ? { definition, ...custom } : {}), mode: config.mode, stage, blockers, ready: blockers.length === 0,
|
|
274
287
|
actor: !definition ? "person" : shown && criteria.blockers.length ? "reviewer/person" : config.definitions ? definition.actor : baseActor(definition.base),
|
|
275
288
|
next: blockers[0] ?? "person may inspect merge readiness; no automatic merge" };
|
|
276
289
|
}
|
|
@@ -434,13 +447,15 @@ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string
|
|
|
434
447
|
if (thread?.isResolved !== false) { blockers.push("bot review thread evidence unavailable"); continue; }
|
|
435
448
|
const comments = thread?.comments;
|
|
436
449
|
if (comments?.pageInfo?.hasNextPage !== false || !Array.isArray(comments.nodes) || comments.nodes.length > 100
|
|
437
|
-
|| comments.nodes.some((comment: {
|
|
450
|
+
|| comments.nodes.some((comment: { createdAt?: string }) => !Number.isFinite(Date.parse(comment?.createdAt ?? "")))) {
|
|
438
451
|
blockers.push("bot review thread evidence unavailable"); continue;
|
|
439
452
|
}
|
|
453
|
+
// #230 item 3: only a `User` comment answers. Every other or missing author kind (Bot, null, Organization, Mannequin,
|
|
454
|
+
// EnterpriseUserAccount, a kind GitHub adds later) is a pending bot comment, so an unresolved thread settles by
|
|
455
|
+
// resolution or a later User comment. No weaker than before, and still no body or login is read.
|
|
440
456
|
let unanswered = false;
|
|
441
|
-
for (const comment of comments.nodes) {
|
|
442
|
-
|
|
443
|
-
else if (comment.author.__typename === "User") unanswered = false;
|
|
457
|
+
for (const comment of comments.nodes as { author?: { __typename?: string | null } | null }[]) {
|
|
458
|
+
unanswered = comment?.author?.__typename !== "User";
|
|
444
459
|
}
|
|
445
460
|
if (unanswered) blockers.push("bot review threads unanswered");
|
|
446
461
|
}
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { types } from "node:util";
|
|
4
4
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -19,6 +19,8 @@ export const SETTING_PEERS = SUPPORTED_PEERS;
|
|
|
19
19
|
/** `null` removes the machine-local value, so the shared file's or the default applies again. */
|
|
20
20
|
export type SettingValue = string | boolean | number | string[] | null;
|
|
21
21
|
export interface SettingDef {
|
|
22
|
+
/** Fixed host policy; no authority can edit or undo this row. */
|
|
23
|
+
locked?: string;
|
|
22
24
|
key: string;
|
|
23
25
|
group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
|
|
24
26
|
label: string;
|
|
@@ -38,6 +40,7 @@ export interface SettingDef {
|
|
|
38
40
|
path: readonly string[];
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
const READ_ONLY_LOCK = "read-only instance: permission mode is locked by its declaration";
|
|
41
44
|
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
42
45
|
const PEERS_MAX = 8;
|
|
43
46
|
const perClass = (name: TaskClass): SettingDef[] => [
|
|
@@ -48,6 +51,7 @@ const perClass = (name: TaskClass): SettingDef[] => [
|
|
|
48
51
|
];
|
|
49
52
|
export const SETTINGS: readonly SettingDef[] = [
|
|
50
53
|
...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
|
|
54
|
+
{ key: "task_sweep.no_writes_turns", group: "Switches", label: "Consecutive owner turns without writes (0 disables)", type: "integer", min: 0, max: Number.MAX_SAFE_INTEGER, store: "config", applies: "live", risk: "raises", path: ["task_sweep", "no_writes_turns"] },
|
|
51
55
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
52
56
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
53
57
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
@@ -71,37 +75,61 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
71
75
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
72
76
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
73
77
|
*/
|
|
74
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances"] as const;
|
|
78
|
+
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "instances", "adapters", "adapter_modules"] as const;
|
|
75
79
|
|
|
76
80
|
/** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
|
|
77
|
-
export function projectSettings(instances?: KindMap): readonly SettingDef[] {
|
|
78
|
-
|
|
79
|
-
|| ![Object.prototype, null].includes(Object.getPrototypeOf(instances)) || Object.getOwnPropertySymbols(instances).length) return SETTINGS;
|
|
81
|
+
export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
|
|
82
|
+
const resolve = adapterCatalog?.definitionOf ?? definitionOf;
|
|
80
83
|
const extra: SettingDef[] = [];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const headless = (peer: string): SettingDef => Object.freeze({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (headless only)`,
|
|
85
|
+
type: "enum", values: Object.freeze(["headless"]), store: "config", applies: "next use", risk: "raises", floor: "headless", path: Object.freeze(["peers", peer, "start_mode"]) });
|
|
86
|
+
for (const adapter of adapterCatalog?.definitions ?? []) {
|
|
87
|
+
if (definitionOf(adapter.kind)) continue;
|
|
88
|
+
const peer = adapter.kind;
|
|
89
|
+
extra.push(
|
|
90
|
+
{ key: `roster.${peer}`, group: "Roster", label: `${adapter.displayName} (${peer}): participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] },
|
|
91
|
+
{ ...SETTINGS.find(def => def.key.startsWith("tasks.wip_limits."))!, key: `tasks.wip_limits.${peer}`, label: `${peer}: planner WIP limit`, path: ["tasks", "wip_limits", peer] },
|
|
92
|
+
headless(peer),
|
|
93
|
+
);
|
|
94
|
+
const values = Object.keys(adapter.permission.genericAcp?.modes ?? {});
|
|
95
|
+
if (!adapter.permission.unmanaged && values.length) for (const runtime of [true, false]) extra.push({
|
|
96
|
+
key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: mode ${runtime ? "now" : "at next peer start"}`,
|
|
97
|
+
type: "enum", values, store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "raises", ...(values.includes("ask") ? { floor: "ask" } : {}), path: runtime ? [peer] : ["permission_modes", peer],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const baseSettings = [...SETTINGS, ...extra];
|
|
101
|
+
const validInstances = instances && typeof instances === "object" && !types.isProxy(instances) && !Array.isArray(instances)
|
|
102
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(instances)) && !Object.getOwnPropertySymbols(instances).length;
|
|
103
|
+
for (const peer of validInstances ? Object.keys(instances!) : []) {
|
|
104
|
+
if (resolve(peer)) continue; // An explicit declaration never changes a canonical setting definition.
|
|
105
|
+
const adapter = resolve(peer, instances);
|
|
84
106
|
if (!adapter) continue;
|
|
107
|
+
const entry = Object.getOwnPropertyDescriptor(instances!, peer)?.value;
|
|
108
|
+
const readOnly = entry && typeof entry === "object" && !types.isProxy(entry) && Object.getOwnPropertyDescriptor(entry, "readOnly")?.value === true && !!adapter.permission.genericAcp?.readOnly;
|
|
85
109
|
const copy = (base: SettingDef, key: string): SettingDef => Object.freeze({ ...base, key,
|
|
86
110
|
label: base.label.replace(`${adapter.kind}:`, `${peer}:`),
|
|
87
111
|
path: Object.freeze(base.path.map(part => part === adapter.kind ? peer : part)),
|
|
88
112
|
...(base.values ? { values: Object.freeze([...base.values]) } : {}),
|
|
89
113
|
});
|
|
90
114
|
for (const prefix of ["roster", "permission", "permission_modes", "tasks.wip_limits"]) {
|
|
91
|
-
|
|
115
|
+
if (readOnly && (prefix === "permission" || prefix === "permission_modes")) continue;
|
|
116
|
+
const base = baseSettings.find(def => def.key === `${prefix}.${adapter.kind}`);
|
|
92
117
|
if (base) extra.push(copy(base, `${prefix}.${peer}`));
|
|
93
118
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
type: "enum", values: Object.freeze(["
|
|
119
|
+
if (readOnly) for (const runtime of [true, false]) extra.push(Object.freeze({
|
|
120
|
+
key: `${runtime ? "permission" : "permission_modes"}.${peer}`, group: "Permissions", label: `${peer}: read-only (locked)`,
|
|
121
|
+
type: "enum", values: Object.freeze(["read-only"]), store: runtime ? "runtime" : "config", applies: runtime ? "live" : "next use", risk: "safe",
|
|
122
|
+
path: Object.freeze(runtime ? [peer] : ["permission_modes", peer]), locked: READ_ONLY_LOCK,
|
|
123
|
+
} satisfies SettingDef));
|
|
124
|
+
extra.push(headless(peer));
|
|
97
125
|
}
|
|
98
126
|
return extra.length ? Object.freeze([...SETTINGS, ...extra]) : SETTINGS;
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/** The definition of a key, or why there is none. */
|
|
102
|
-
export function settingDef(key: unknown, instances?: KindMap): SettingDef | string {
|
|
130
|
+
export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
|
|
103
131
|
if (typeof key !== "string" || key.length > 80) return "unknown setting";
|
|
104
|
-
const found = projectSettings(instances).find((s) => s.key === key);
|
|
132
|
+
const found = projectSettings(instances, adapterCatalog).find((s) => s.key === key);
|
|
105
133
|
if (found) return found;
|
|
106
134
|
if (NEVER_EDITABLE.some((never) => key === never || key.startsWith(`${never}.`))) return `${key} chooses what the hub runs or where secrets and task text go: it is never editable from settings; edit .agenthub/config.local.json in your editor`;
|
|
107
135
|
return "unknown setting; run ahub settings list";
|
|
@@ -109,6 +137,7 @@ export function settingDef(key: unknown, instances?: KindMap): SettingDef | stri
|
|
|
109
137
|
|
|
110
138
|
/** The checked value, or a thrown reason. `routes`: the route ids the project's routing defines. */
|
|
111
139
|
export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonly string[] = []): SettingValue {
|
|
140
|
+
if (def.locked) throw new Error(def.locked);
|
|
112
141
|
if (raw === null) {
|
|
113
142
|
if (def.store === "runtime") throw new Error(`${def.key} has no stored value to remove`);
|
|
114
143
|
return null;
|
|
@@ -151,6 +180,7 @@ export type SettingAuthority = "ordinary" | "settings" | "terminal";
|
|
|
151
180
|
/** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
|
|
152
181
|
export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
153
182
|
if (typeof def === "string") return def || "invalid setting definition";
|
|
183
|
+
if (def.locked) return def.locked;
|
|
154
184
|
if (def.risk === "safe" || authority !== "ordinary") return undefined;
|
|
155
185
|
if (value !== null && value === def.floor) return undefined;
|
|
156
186
|
return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
|
|
@@ -354,6 +384,10 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
|
|
|
354
384
|
export function storedLocally(cwd: string, def: SettingDef): boolean {
|
|
355
385
|
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", CONFIG_LOCAL), "utf8")), def.path) !== undefined; } catch { return false; }
|
|
356
386
|
}
|
|
387
|
+
/** What the shared `.agenthub/config.json` holds for the key, or undefined: a removal here only ever touches the machine-local file. */
|
|
388
|
+
export function sharedValue(cwd: string, def: SettingDef): unknown {
|
|
389
|
+
try { return valueAt(JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8")), def.path); } catch { return undefined; }
|
|
390
|
+
}
|
|
357
391
|
/**
|
|
358
392
|
* For a config setting: the value the config files would hold for it after the pending undo (the previous version of
|
|
359
393
|
* the machine-local file, else the shared file), or, with `inherit`, after the machine-local value is removed (the
|
|
@@ -390,6 +424,7 @@ export function ignoredLine(ignored: readonly string[] | undefined, def: Setting
|
|
|
390
424
|
});
|
|
391
425
|
}
|
|
392
426
|
export interface SettingRow {
|
|
427
|
+
locked?: string;
|
|
393
428
|
key: string; group: SettingDef["group"]; label: string; type: SettingDef["type"]; values?: readonly string[]; min?: number; max?: number;
|
|
394
429
|
/** The file a write goes to, or "this hub run". */
|
|
395
430
|
file: string;
|
|
@@ -418,6 +453,7 @@ export function settingRows(input: {
|
|
|
418
453
|
cwd: string; routing: Routing;
|
|
419
454
|
/** Captured trusted declarations for this daemon/project, never taken from a settings request. */
|
|
420
455
|
instances?: KindMap;
|
|
456
|
+
adapterCatalog: ProjectAdapterCatalog;
|
|
421
457
|
running: (path: readonly string[]) => unknown;
|
|
422
458
|
next: ((path: readonly string[]) => unknown) | undefined;
|
|
423
459
|
mode: (peer: string) => { value?: string; note?: string };
|
|
@@ -432,9 +468,11 @@ export function settingRows(input: {
|
|
|
432
468
|
const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
|
|
433
469
|
const routes = Object.keys(input.routing.hub_routes ?? {});
|
|
434
470
|
const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
|
|
435
|
-
return projectSettings(input.instances).map((def): SettingRow => {
|
|
471
|
+
return projectSettings(input.instances, input.adapterCatalog).map((def): SettingRow => {
|
|
436
472
|
const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
|
|
437
|
-
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances) } : {}) };
|
|
473
|
+
...(def.key === "scoped_grants.protect_instructions" ? { hint: "AGENTS.md, CLAUDE.md and QWEN.md remain grantable by default; enabling this asks before scoped writes and edits" } : def.group === "Permissions" ? { hint: permissionBoundary(def.path.at(-1)!, input.instances, input.adapterCatalog) } : {}) };
|
|
474
|
+
if (def.locked) return { ...base, values: def.values, file: def.store === "runtime" ? "this hub run" : CONFIG_LOCAL,
|
|
475
|
+
source: "instance declaration", value: "read-only", fileValue: "read-only", locked: def.locked, note: def.locked };
|
|
438
476
|
if (def.store === "runtime") {
|
|
439
477
|
const mode = input.mode(def.path[0]!);
|
|
440
478
|
return { ...base, values: def.values!, file: "this hub run", value: mode.value ?? null, fileValue: mode.value ?? null, source: "this hub run", ...(mode.note ? { note: mode.note } : {}) };
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -20,19 +20,21 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
|
20
20
|
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
21
21
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
/** `now` is a host-only clock (#661): a test holds it fixed so the budget never runs out whatever the runner's speed. Production leaves it unset (performance.now). */
|
|
24
|
+
export interface SnapshotBudget { deadline: number; now?: () => number; }
|
|
25
|
+
export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget.now() : performance.now();
|
|
24
26
|
export class SnapshotBudgetExceeded extends Error {
|
|
25
27
|
constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
|
|
26
28
|
}
|
|
27
|
-
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget) => {
|
|
29
|
+
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number) => {
|
|
28
30
|
const argv = repoGitArgs(top, args);
|
|
29
|
-
const remaining = budget ? budget.deadline -
|
|
31
|
+
const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
|
|
30
32
|
if (remaining <= 0) throw new SnapshotBudgetExceeded();
|
|
31
33
|
const result = hubGitSync(argv, {
|
|
32
|
-
encoding: "utf8", env: { ...repoGitEnv(top), ...env },
|
|
34
|
+
encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
33
35
|
timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
|
|
34
36
|
});
|
|
35
|
-
if (budget && (
|
|
37
|
+
if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
|
|
36
38
|
return result;
|
|
37
39
|
};
|
|
38
40
|
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
@@ -141,6 +143,30 @@ export function headState(repo: Repo, budget?: SnapshotBudget): { head: string |
|
|
|
141
143
|
return current.status === 1 ? { head: null, tree: null } : undefined;
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
/** One bounded probe covers shared local/remote branch refs, including commit and push progress. Names never escape. */
|
|
147
|
+
export function branchHeads(repo: Repo, budget?: SnapshotBudget): string | undefined {
|
|
148
|
+
const result = git(repo, ["for-each-ref", "--sort=refname", "--format=%(refname) %(objectname)", "refs/heads/", "refs/remotes/"], {}, budget, 1024 * 1024);
|
|
149
|
+
if (result.status !== 0 || result.error) return undefined;
|
|
150
|
+
const digest = createHash("sha256").update(result.stdout).digest("hex");
|
|
151
|
+
if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
|
|
152
|
+
return digest;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Negative canonical evidence needs exclusive scope, including detached linked worktrees. Paths stay host-only. */
|
|
156
|
+
export function soleWorktree(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
157
|
+
const result = git(repo, ["worktree", "list", "--porcelain", "-z"], {}, budget, 1024 * 1024);
|
|
158
|
+
if (result.status !== 0 || result.error) return undefined;
|
|
159
|
+
// A project-bound shadow registers its private bare Git directory, not its explicit --work-tree root.
|
|
160
|
+
const registeredRoot = repo.shadow ? repo.dir : repo.top;
|
|
161
|
+
let count = 0;
|
|
162
|
+
for (const field of result.stdout.split("\0")) {
|
|
163
|
+
if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded();
|
|
164
|
+
if (!field.startsWith("worktree ")) continue;
|
|
165
|
+
if (field.slice(9) !== registeredRoot || ++count > 1) return false;
|
|
166
|
+
}
|
|
167
|
+
return count === 1 ? true : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
144
170
|
/** A bounded append cursor; raw reflog identities and messages never leave the reader. */
|
|
145
171
|
export interface HeadMoveCursor {
|
|
146
172
|
readonly head: string | null;
|
|
@@ -155,7 +181,7 @@ export interface HeadMoveCursor {
|
|
|
155
181
|
const HEAD_LOG_TAIL_BYTES = 4096;
|
|
156
182
|
const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
|
|
157
183
|
const HEAD_LOG_MAX_MOVES = 64;
|
|
158
|
-
const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget &&
|
|
184
|
+
const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && budgetNow(budget) >= budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
159
185
|
const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
|
|
160
186
|
function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
|
|
161
187
|
let fd: number | undefined;
|
package/src/hub/start-mode.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
|
|
@@ -20,27 +20,29 @@ export const isStartablePeer = (value: unknown): value is StartablePeer => typeo
|
|
|
20
20
|
|
|
21
21
|
export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
|
|
22
22
|
/** The checked per-peer start modes; only peers with both forms may be configured. */
|
|
23
|
-
export function peerStartConfig(value: unknown, instances?: KindMap): PeerStartConfig {
|
|
23
|
+
export function peerStartConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): PeerStartConfig {
|
|
24
24
|
if (value === undefined) return {};
|
|
25
25
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
|
|
26
26
|
const result: PeerStartConfig = {};
|
|
27
27
|
for (const [peer, block] of Object.entries(value)) {
|
|
28
|
-
const
|
|
29
|
-
|
|
28
|
+
const configured = !!catalog?.definitionOf(peer) && !definitionOf(peer);
|
|
29
|
+
const extra = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && !!(catalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
30
|
+
if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer) && !extra && !configured) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
|
|
30
31
|
if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
|
|
32
|
+
if (Object.keys(block).some(key => key !== "start_mode")) throw new Error(`peers.${peer}: unsupported setting`);
|
|
31
33
|
const mode = (block as { start_mode?: unknown }).start_mode;
|
|
32
34
|
if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
|
|
33
|
-
if (extra && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
|
|
35
|
+
if ((extra || configured) && mode !== undefined && mode !== "headless") throw new Error(`peers.${peer}.start_mode: extra instances require headless mode in I2`);
|
|
34
36
|
result[peer] = mode === undefined ? {} : { start_mode: mode as StartMode };
|
|
35
37
|
}
|
|
36
38
|
return result;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
|
|
40
|
-
export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap): StartMode {
|
|
41
|
-
if (definitionOf(peer, instances)?.kind !== peer && definitionOf(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
|
|
42
|
+
export function startModeOf(peers: PeerStartConfig | undefined, peer: string, instances?: KindMap, catalog?: ProjectAdapterCatalog): StartMode {
|
|
43
|
+
if ((catalog?.definitionOf ?? definitionOf)(peer, instances)?.kind !== peer && (catalog?.definitionOf ?? definitionOf)(peer, instances)) return peers?.[peer]?.start_mode ?? "headless";
|
|
42
44
|
if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
|
|
43
|
-
return definitionOf(peer)?.start.defaultForm ?? "headless";
|
|
45
|
+
return (catalog?.definitionOf ?? definitionOf)(peer)?.start.defaultForm ?? "headless";
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
package/src/hub/task-sweep.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface SweepRecord {
|
|
|
10
10
|
}
|
|
11
11
|
export interface TaskSweepConfig {
|
|
12
12
|
enabled: boolean;
|
|
13
|
+
no_writes_turns?: number;
|
|
13
14
|
interval_s: number;
|
|
14
15
|
unaccepted_min: number;
|
|
15
16
|
idle_min: number;
|
|
@@ -19,7 +20,7 @@ export interface TaskSweepConfig {
|
|
|
19
20
|
auto_reassign: boolean;
|
|
20
21
|
}
|
|
21
22
|
export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
|
|
22
|
-
enabled: false, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
|
|
23
|
+
enabled: false, no_writes_turns: 2, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
/** Config cannot silently enable a sweep or reassignment through a truthy string. */
|
|
@@ -33,6 +34,11 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
|
|
|
33
34
|
if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
|
|
34
35
|
out[key] = values[key];
|
|
35
36
|
}
|
|
37
|
+
if (values.no_writes_turns !== undefined) {
|
|
38
|
+
const limit = values.no_writes_turns;
|
|
39
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) throw new Error("task_sweep.no_writes_turns must be a safe nonnegative integer");
|
|
40
|
+
out.no_writes_turns = limit;
|
|
41
|
+
}
|
|
36
42
|
for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"] as const) {
|
|
37
43
|
if (values[key] === undefined) continue;
|
|
38
44
|
const value = values[key];
|
|
@@ -77,7 +83,7 @@ export interface PlannerGroomingOptions {
|
|
|
77
83
|
acceptedIssues?: ReadonlyMap<number, { repo: string; issue: number; lastEditedAt: string | null }>;
|
|
78
84
|
}
|
|
79
85
|
const GROOMING_TASK_LIMIT = 500, GROOMING_ITEM_LIMIT = 256;
|
|
80
|
-
const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency"]);
|
|
86
|
+
const GROOMING_CODES = new Set(["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "runner", "approvals", "quota", "dependency", "no-writes"]);
|
|
81
87
|
const groomingRepo = (value: unknown): string | undefined => {
|
|
82
88
|
if (typeof value !== "string") return undefined;
|
|
83
89
|
if (value.startsWith("path:/") && value.length <= 4096 && !/\p{Cc}/u.test(value)) return value;
|