@openprd/cli 0.1.19 → 0.1.21
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/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
- package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
- package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
- package/.openprd/design/active/asset-spec.md +19 -14
- package/.openprd/design/active/direction-plan.md +19 -3
- package/.openprd/design/active/facts-sheet.md +16 -7
- package/.openprd/design/active/image-preflight.md +6 -5
- package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
- package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
- package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
- package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
- package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
- package/.openprd/design/active/selected-direction.md +23 -9
- package/.openprd/engagements/active/control-plane-architecture.json +203 -0
- package/.openprd/engagements/active/control-plane-intake.json +418 -0
- package/.openprd/engagements/active/prd.md +183 -119
- package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
- package/.openprd/i18n-config.json +12 -0
- package/.openprd/ledger/events.jsonl +6 -0
- package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
- package/.openprd/manifest.json +21 -0
- package/AGENTS.md +1 -0
- package/README.md +2 -0
- package/README_EN.md +2 -0
- package/package.json +1 -1
- package/skills/openprd-frontend-design/SKILL.md +16 -0
- package/skills/openprd-harness/SKILL.md +1 -1
- package/skills/openprd-quality/SKILL.md +1 -1
- package/skills/openprd-requirement-intake/SKILL.md +1 -1
- package/skills/openprd-shared/SKILL.md +1 -1
- package/src/adapters/adapter-spi.js +193 -0
- package/src/adapters/capability-envelope.js +98 -0
- package/src/adapters/event-normalizer.js +55 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/install-safety.js +249 -0
- package/src/agent-canonical-content.js +4 -2
- package/src/agent-integration.js +147 -42
- package/src/cli/args.js +63 -4
- package/src/cli/gate-print.js +17 -0
- package/src/cli/quality-commands.js +18 -0
- package/src/cli/quality-print.js +10 -0
- package/src/cli/runtime-print.js +24 -0
- package/src/codex-hook-runner-template.mjs +65 -77
- package/src/codex-runtime.js +48 -5
- package/src/context/cache.js +245 -0
- package/src/context/compiler.js +438 -0
- package/src/context/constants.js +30 -0
- package/src/context/index.js +39 -0
- package/src/context/redaction.js +84 -0
- package/src/context/stable.js +69 -0
- package/src/context/telemetry.js +42 -0
- package/src/dev-standards.js +57 -0
- package/src/fleet.js +112 -95
- package/src/gates/index.js +2 -0
- package/src/gates/scoped-gates.js +256 -0
- package/src/gates/store.js +126 -0
- package/src/gates/workspace.js +41 -0
- package/src/html-artifacts.js +725 -28
- package/src/kernel/atomic-store.js +299 -0
- package/src/kernel/event-envelope.js +166 -0
- package/src/kernel/index.js +4 -0
- package/src/kernel/project-ledger.js +467 -0
- package/src/kernel/project-manifest.js +205 -0
- package/src/knowledge-v3/index.js +1 -0
- package/src/knowledge-v3/lifecycle.js +290 -0
- package/src/knowledge.js +14 -7
- package/src/openprd.js +71 -2
- package/src/review-model.js +413 -0
- package/src/review-presentation.js +1 -1
- package/src/run-harness.js +431 -37
- package/src/runtime/cli_runtime_README.md +28 -0
- package/src/runtime/errors.js +66 -0
- package/src/runtime/index.js +44 -0
- package/src/runtime/lane-schema.js +141 -0
- package/src/runtime/lane-store.js +279 -0
- package/src/runtime/task-runtime.js +449 -0
- package/src/runtime/workspace.js +179 -0
- package/src/runtime/write-set.js +206 -0
- package/src/session-binding.js +16 -3
- package/src/session-registry.js +59 -1
- package/src/upgrade/fleet-mutation.js +166 -0
- package/src/upgrade/fleet-transaction.js +398 -0
- package/src/upgrade/transaction-store.js +416 -0
- package/src/visual-compare-core.js +66 -27
- package/src/visual-compare.js +18 -12
- package/src/workspace-core.js +109 -7
- package/src/workspace-registry.js +39 -1
- package/src/workspace-workflow.js +18 -15
|
@@ -765,45 +765,22 @@ function detectExecutionSignals(eventName, payload) {
|
|
|
765
765
|
};
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
-
function latestSessionRegistryEntry(root, sessionId) {
|
|
769
|
-
if (!sessionId) {
|
|
770
|
-
return null;
|
|
771
|
-
}
|
|
772
|
-
const filePath = sessionRegistryPath();
|
|
773
|
-
if (!fs.existsSync(filePath)) {
|
|
774
|
-
return null;
|
|
775
|
-
}
|
|
776
|
-
const workspaceRoot = path.resolve(root);
|
|
777
|
-
let latest = null;
|
|
778
|
-
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
|
779
|
-
if (!line.trim()) {
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
|
-
try {
|
|
783
|
-
const entry = JSON.parse(line);
|
|
784
|
-
if (entry?.sessionId !== sessionId) {
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
787
|
-
const entryRoot = entry?.workspaceRoot ? path.resolve(String(entry.workspaceRoot)) : null;
|
|
788
|
-
if (entryRoot && entryRoot !== workspaceRoot) {
|
|
789
|
-
continue;
|
|
790
|
-
}
|
|
791
|
-
latest = entry;
|
|
792
|
-
} catch {}
|
|
793
|
-
}
|
|
794
|
-
return latest;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
768
|
function rememberAutomationMode(root, sessionId, payload, signals, laneKind) {
|
|
798
769
|
if (!sessionId) {
|
|
799
770
|
return null;
|
|
800
771
|
}
|
|
801
|
-
|
|
772
|
+
const gateStatus = laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in';
|
|
773
|
+
const existing = readSessionBinding(root, sessionId);
|
|
774
|
+
// 每个 hook 事件都会经过这里;同一会话的 lane 记忆只需要落一次盘。
|
|
775
|
+
if (existing?.laneKind === laneKind && existing?.gateStatus === gateStatus) {
|
|
776
|
+
return existing;
|
|
777
|
+
}
|
|
778
|
+
return writeSessionBinding(root, sessionId, {
|
|
802
779
|
laneKind,
|
|
803
780
|
tool: 'openprd-hook',
|
|
804
781
|
promptPreview: preview(promptText(payload) || payloadText(payload), 500),
|
|
805
782
|
gateActive: false,
|
|
806
|
-
gateStatus
|
|
783
|
+
gateStatus,
|
|
807
784
|
title: laneKind === 'automation'
|
|
808
785
|
? 'Unattended automation bypassed OpenPrd'
|
|
809
786
|
: 'Unattended automation explicitly enabled OpenPrd',
|
|
@@ -820,7 +797,7 @@ function automationBypassDecision(root, eventName, payload, sessionId) {
|
|
|
820
797
|
if (signals.isAutomation) {
|
|
821
798
|
return { bypass: true, optIn: false, remembered: false, signals };
|
|
822
799
|
}
|
|
823
|
-
const remembered = eventName === 'UserPromptSubmit' ? null :
|
|
800
|
+
const remembered = eventName === 'UserPromptSubmit' ? null : readSessionBinding(root, sessionId);
|
|
824
801
|
if (remembered?.laneKind === 'automation' && remembered?.gateStatus === 'automation-safe-mode') {
|
|
825
802
|
return { bypass: true, optIn: false, remembered: true, signals };
|
|
826
803
|
}
|
|
@@ -912,44 +889,68 @@ function readSessionBinding(root, sessionId = null) {
|
|
|
912
889
|
return readJsonSync(sessionBindingPath(root, sessionId), null);
|
|
913
890
|
}
|
|
914
891
|
|
|
915
|
-
function
|
|
892
|
+
function appendJsonLineAtomic(filePath, value) {
|
|
916
893
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
917
|
-
fs.
|
|
894
|
+
const fd = fs.openSync(filePath, 'a', 0o600);
|
|
895
|
+
try {
|
|
896
|
+
fs.writeSync(fd, `${JSON.stringify(value)}\n`);
|
|
897
|
+
fs.fsyncSync(fd);
|
|
898
|
+
} finally {
|
|
899
|
+
fs.closeSync(fd);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function sessionRegistryProjection(entry = {}) {
|
|
904
|
+
return {
|
|
905
|
+
sessionId: entry.sessionId ?? null,
|
|
906
|
+
workspaceRoot: entry.workspaceRoot ?? null,
|
|
907
|
+
laneKind: entry.laneKind ?? 'requirement',
|
|
908
|
+
tool: entry.tool ?? 'codex',
|
|
909
|
+
threadId: entry.threadId ?? null,
|
|
910
|
+
changeId: entry.changeId ?? null,
|
|
911
|
+
taskHandle: entry.taskHandle ?? null,
|
|
912
|
+
workUnitId: entry.workUnitId ?? null,
|
|
913
|
+
versionId: entry.versionId ?? null,
|
|
914
|
+
digest: entry.digest ?? null,
|
|
915
|
+
title: entry.title ?? null,
|
|
916
|
+
targetRoot: entry.targetRoot ?? null,
|
|
917
|
+
promptPreview: entry.promptPreview ?? null,
|
|
918
|
+
reviewStatus: entry.reviewStatus ?? null,
|
|
919
|
+
gateStatus: entry.gateStatus ?? null,
|
|
920
|
+
gateActive: entry.gateActive === true,
|
|
921
|
+
};
|
|
918
922
|
}
|
|
919
923
|
|
|
920
|
-
function writeSessionRegistryEntry(root, sessionId,
|
|
924
|
+
function writeSessionRegistryEntry(root, sessionId, binding, previous = null) {
|
|
921
925
|
if (!sessionId) {
|
|
922
926
|
return null;
|
|
923
927
|
}
|
|
924
928
|
const workspaceRoot = path.resolve(root);
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
version: 1,
|
|
929
|
+
const projection = sessionRegistryProjection({
|
|
930
|
+
...binding,
|
|
928
931
|
sessionId,
|
|
929
932
|
workspaceRoot,
|
|
933
|
+
});
|
|
934
|
+
const previousProjection = previous
|
|
935
|
+
? sessionRegistryProjection({ ...previous, sessionId, workspaceRoot })
|
|
936
|
+
: null;
|
|
937
|
+
if (previousProjection && JSON.stringify(previousProjection) === JSON.stringify(projection)) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const recordedAt = binding.updatedAt || now();
|
|
941
|
+
const entry = {
|
|
942
|
+
version: 1,
|
|
943
|
+
...projection,
|
|
930
944
|
realpath: workspaceRoot,
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
taskHandle: patch.taskHandle || null,
|
|
936
|
-
workUnitId: patch.workUnitId || null,
|
|
937
|
-
versionId: patch.versionId || null,
|
|
938
|
-
digest: patch.digest || null,
|
|
939
|
-
title: patch.title || null,
|
|
940
|
-
targetRoot: patch.targetRoot ? path.resolve(patch.targetRoot) : null,
|
|
941
|
-
promptPreview: patch.promptPreview || null,
|
|
942
|
-
reviewStatus: patch.reviewStatus || null,
|
|
943
|
-
gateStatus: patch.gateStatus || null,
|
|
944
|
-
gateActive: patch.gateActive === true,
|
|
945
|
-
statePath: patch.statePath || sessionStatePath(root, sessionId),
|
|
946
|
-
bindingPath: patch.bindingPath || sessionBindingPath(root, sessionId),
|
|
947
|
-
firstRegisteredAt: patch.firstRegisteredAt || recordedAt,
|
|
945
|
+
targetRoot: projection.targetRoot ? path.resolve(projection.targetRoot) : null,
|
|
946
|
+
statePath: sessionStatePath(root, sessionId),
|
|
947
|
+
bindingPath: sessionBindingPath(root, sessionId),
|
|
948
|
+
firstRegisteredAt: previous?.createdAt || binding.createdAt || recordedAt,
|
|
948
949
|
lastRegisteredAt: recordedAt,
|
|
949
|
-
lastUpdatedAt:
|
|
950
|
+
lastUpdatedAt: recordedAt,
|
|
950
951
|
recordedAt,
|
|
951
952
|
};
|
|
952
|
-
|
|
953
|
+
appendJsonLineAtomic(sessionRegistryPath(), entry);
|
|
953
954
|
return entry;
|
|
954
955
|
}
|
|
955
956
|
|
|
@@ -968,25 +969,7 @@ function writeSessionBinding(root, sessionId, patch = {}) {
|
|
|
968
969
|
updatedAt: patch.updatedAt ?? now(),
|
|
969
970
|
};
|
|
970
971
|
writeJsonSync(filePath, next);
|
|
971
|
-
writeSessionRegistryEntry(root, sessionId,
|
|
972
|
-
laneKind: patch.laneKind ?? previous?.laneKind ?? 'requirement',
|
|
973
|
-
tool: patch.tool ?? previous?.tool ?? 'codex',
|
|
974
|
-
threadId: patch.threadId ?? previous?.threadId ?? null,
|
|
975
|
-
changeId: patch.changeId ?? next.changeId ?? null,
|
|
976
|
-
taskHandle: patch.taskHandle ?? next.taskHandle ?? null,
|
|
977
|
-
workUnitId: patch.workUnitId ?? next.workUnitId ?? null,
|
|
978
|
-
versionId: patch.versionId ?? next.versionId ?? null,
|
|
979
|
-
digest: patch.digest ?? next.digest ?? null,
|
|
980
|
-
title: patch.title ?? next.title ?? null,
|
|
981
|
-
targetRoot: patch.targetRoot ?? next.targetRoot ?? null,
|
|
982
|
-
promptPreview: patch.promptPreview ?? next.promptPreview ?? null,
|
|
983
|
-
reviewStatus: patch.reviewStatus ?? next.reviewStatus ?? null,
|
|
984
|
-
gateStatus: patch.gateStatus ?? next.gateStatus ?? null,
|
|
985
|
-
gateActive: patch.gateActive ?? next.gateActive ?? false,
|
|
986
|
-
bindingPath: filePath,
|
|
987
|
-
statePath: sessionStatePath(root, sessionId),
|
|
988
|
-
updatedAt: next.updatedAt,
|
|
989
|
-
});
|
|
972
|
+
writeSessionRegistryEntry(root, sessionId, next, previous);
|
|
990
973
|
return next;
|
|
991
974
|
}
|
|
992
975
|
|
|
@@ -1561,6 +1544,9 @@ function knowledgeSkillContextLines(knowledgeSkills) {
|
|
|
1561
1544
|
}
|
|
1562
1545
|
|
|
1563
1546
|
function renderRunContextText(result) {
|
|
1547
|
+
if (String(result?.contextText || '').trim()) {
|
|
1548
|
+
return String(result.contextText).trim();
|
|
1549
|
+
}
|
|
1564
1550
|
const lines = [
|
|
1565
1551
|
'当前进展参考',
|
|
1566
1552
|
'当前项目: ' + result.projectRoot,
|
|
@@ -3507,7 +3493,9 @@ function largeUiVisualDirectionMessage() {
|
|
|
3507
3493
|
'OpenPrd 大界面改动视觉方案评审:',
|
|
3508
3494
|
'位置: 需求分流之后、PRD 定稿或实现开工之前;它不同于 review.html,也不同于实现后的 visual-compare。',
|
|
3509
3495
|
'判断: 先从用户目标、信息架构变化、视觉决策成本和验收风险判断是否需要方向评审,不用关键词触发。会决定首屏、核心布局、主视觉、关键路径、组件层级/密度,或用户需要先选设计方向时触发。',
|
|
3510
|
-
'
|
|
3496
|
+
'自动性: 用户不需要额外提出“请生图”。只要语义判断命中大界面改动或视觉决策成本高,就主动完成 3 方向生成;卡片宽度、间距、对齐、颜色、圆角、字号等轻量微调不触发。',
|
|
3497
|
+
'步骤: 已有界面时,Codex 原生 App 用 Computer Use,Codex 网页环境用 Chrome 插件的任务专用窗口,Cursor 用当前可用的浏览器或项目预览能力,进入对应功能并截当前真实界面。三个方向必须把同一张截图作为 image-to-image 参考;除非用户明确要求换风格,否则锁定现有字体角色、色彩系统、明暗模式、表面语言、圆角/边框/阴影、图标语言、平台框架和信息密度,只探索布局、信息层级、导航、关键路径和空间构图。冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写 design brief,不伪造修改前截图。brief 必须写清用途、受众、气质端点、约束和记忆点,3 个方向要分别说明审美主张和主动避开的模板味。然后按生图路由调用内置生图工具(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`;都不可用时按 `.openprd/harness/image-generation-preference.json` 的用户已确认偏好,没有偏好先问用户)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
|
|
3498
|
+
'质量: 每个方向都要从用途、用户、业务约束、核心动作和唯一记忆点出发,同时检查字体角色、色彩、空间构图、信息层级、表面/图标语言和动效含义;拒绝无依据蓝紫渐变、默认字体、等权白卡堆叠、过量大圆角、无语境装饰和只换颜色的伪方向。',
|
|
3511
3499
|
'交互: 主动追问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现。只有用户确认纳入后续对比或继续实现后,才把选定方向、整张图或其中子图整理成 reference-set 并写入 `.openprd/harness/visual-reviews/`;用户确认前,不进入大 UI 实现,也不要声称界面方案已定。',
|
|
3512
3500
|
'多对象参考处理: 如果候选效果图是一张图里包含多个子图、网格、多对象或多方向,不要拿整板直接硬比;先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>`,检查 contact sheet,确认 reference-set 后再逐项进入实现和验收。',
|
|
3513
3501
|
].join('\n');
|
package/src/codex-runtime.js
CHANGED
|
@@ -89,6 +89,29 @@ function runProcess(command, args = [], options = {}) {
|
|
|
89
89
|
});
|
|
90
90
|
let stdout = '';
|
|
91
91
|
let stderr = '';
|
|
92
|
+
let settled = false;
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
95
|
+
? Math.max(1, Number(options.timeoutMs))
|
|
96
|
+
: 15000;
|
|
97
|
+
const killGraceMs = Number.isFinite(options.killGraceMs)
|
|
98
|
+
? Math.max(1, Number(options.killGraceMs))
|
|
99
|
+
: 1000;
|
|
100
|
+
let killTimer = null;
|
|
101
|
+
const timeoutTimer = setTimeout(() => {
|
|
102
|
+
timedOut = true;
|
|
103
|
+
child.kill('SIGTERM');
|
|
104
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
|
|
105
|
+
killTimer.unref?.();
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
timeoutTimer.unref?.();
|
|
108
|
+
const finish = (payload) => {
|
|
109
|
+
if (settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timeoutTimer);
|
|
112
|
+
if (killTimer) clearTimeout(killTimer);
|
|
113
|
+
resolve(payload);
|
|
114
|
+
};
|
|
92
115
|
child.stdout.on('data', (chunk) => {
|
|
93
116
|
stdout = `${stdout}${chunk}`.slice(-64000);
|
|
94
117
|
});
|
|
@@ -96,7 +119,7 @@ function runProcess(command, args = [], options = {}) {
|
|
|
96
119
|
stderr = `${stderr}${chunk}`.slice(-64000);
|
|
97
120
|
});
|
|
98
121
|
child.on('error', (error) => {
|
|
99
|
-
|
|
122
|
+
finish({
|
|
100
123
|
ok: false,
|
|
101
124
|
command,
|
|
102
125
|
args,
|
|
@@ -104,19 +127,22 @@ function runProcess(command, args = [], options = {}) {
|
|
|
104
127
|
exitCode: null,
|
|
105
128
|
stdout,
|
|
106
129
|
stderr,
|
|
130
|
+
timedOut,
|
|
107
131
|
error: error.message,
|
|
108
132
|
});
|
|
109
133
|
});
|
|
110
|
-
child.on('close', (exitCode) => {
|
|
111
|
-
|
|
112
|
-
ok: exitCode === 0,
|
|
134
|
+
child.on('close', (exitCode, signal) => {
|
|
135
|
+
finish({
|
|
136
|
+
ok: !timedOut && exitCode === 0,
|
|
113
137
|
command,
|
|
114
138
|
args,
|
|
115
139
|
display: invocation.display,
|
|
116
140
|
exitCode,
|
|
141
|
+
signal,
|
|
117
142
|
stdout,
|
|
118
143
|
stderr,
|
|
119
|
-
|
|
144
|
+
timedOut,
|
|
145
|
+
error: timedOut ? `Command timed out after ${timeoutMs}ms.` : null,
|
|
120
146
|
});
|
|
121
147
|
});
|
|
122
148
|
});
|
|
@@ -131,6 +157,8 @@ function normalizeRunResult(command, args, result = {}) {
|
|
|
131
157
|
exitCode: result.exitCode ?? result.status ?? null,
|
|
132
158
|
stdout: String(result.stdout ?? ''),
|
|
133
159
|
stderr: String(result.stderr ?? ''),
|
|
160
|
+
timedOut: Boolean(result.timedOut),
|
|
161
|
+
signal: result.signal ? String(result.signal) : null,
|
|
134
162
|
error: result.error ? String(result.error) : null,
|
|
135
163
|
};
|
|
136
164
|
}
|
|
@@ -178,6 +206,17 @@ export function diagnoseCodexVersionFailure(result, options = {}) {
|
|
|
178
206
|
};
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
if (result.timedOut) {
|
|
210
|
+
return {
|
|
211
|
+
type: 'version-check-timeout',
|
|
212
|
+
summary: `Codex CLI 健康检查超时:codex --version 在 ${options.timeoutMs ?? 15000}ms 内没有返回。`,
|
|
213
|
+
missingPackage: null,
|
|
214
|
+
repairCommand,
|
|
215
|
+
manualCommand: repairCommand.display,
|
|
216
|
+
output,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
181
220
|
if (
|
|
182
221
|
result.exitCode === null
|
|
183
222
|
&& /(ENOENT|not found|command not found|no such file|spawn .*enoent)/i.test(output)
|
|
@@ -212,6 +251,8 @@ export async function checkCodexCliHealth(options = {}) {
|
|
|
212
251
|
await runner(command, args, {
|
|
213
252
|
cwd: options.cwd,
|
|
214
253
|
env: options.env ?? process.env,
|
|
254
|
+
timeoutMs: options.timeoutMs ?? 15000,
|
|
255
|
+
killGraceMs: options.killGraceMs,
|
|
215
256
|
}),
|
|
216
257
|
);
|
|
217
258
|
const repairCommand = buildCodexRepairCommand(options);
|
|
@@ -257,6 +298,8 @@ export async function repairCodexCli(options = {}) {
|
|
|
257
298
|
await runner(commandInfo.command, commandInfo.args, {
|
|
258
299
|
cwd: options.cwd,
|
|
259
300
|
env: options.env ?? process.env,
|
|
301
|
+
timeoutMs: options.repairTimeoutMs ?? 300000,
|
|
302
|
+
killGraceMs: options.killGraceMs,
|
|
260
303
|
}),
|
|
261
304
|
);
|
|
262
305
|
const recheck = result.ok ? await checkCodexCliHealth(options) : null;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildContextRequestFingerprint,
|
|
7
|
+
compileContextCapsule,
|
|
8
|
+
verifyContextCapsule,
|
|
9
|
+
} from './compiler.js';
|
|
10
|
+
import {
|
|
11
|
+
CONTEXT_CACHE_SCHEMA_VERSION,
|
|
12
|
+
CONTEXT_COMPILER_VERSION,
|
|
13
|
+
} from './constants.js';
|
|
14
|
+
import { cloneStable, compareStableText, digestStable } from './stable.js';
|
|
15
|
+
|
|
16
|
+
function assertCacheKey(key) {
|
|
17
|
+
const normalized = String(key ?? '');
|
|
18
|
+
if (!/^context-v1-[a-f0-9]{64}$/.test(normalized)) {
|
|
19
|
+
throw new TypeError(`Invalid context cache key: ${normalized}`);
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildContextCacheKey(input, { previousCapsule = null } = {}) {
|
|
25
|
+
const fingerprint = buildContextRequestFingerprint(input, { previousCapsule });
|
|
26
|
+
const hash = digestStable({
|
|
27
|
+
compilerVersion: CONTEXT_COMPILER_VERSION,
|
|
28
|
+
fingerprint,
|
|
29
|
+
}).slice('sha256:'.length);
|
|
30
|
+
return `context-v1-${hash}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function contextCachePath(cacheDir, key) {
|
|
34
|
+
return path.join(cacheDir, `${assertCacheKey(key)}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cacheEnvelope(key, capsule) {
|
|
38
|
+
if (!verifyContextCapsule(capsule)) {
|
|
39
|
+
throw new TypeError('Refusing to cache an invalid ContextCapsule.');
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
|
|
43
|
+
key: assertCacheKey(key),
|
|
44
|
+
revision: capsule.revision,
|
|
45
|
+
capsuleDigest: capsule.digest,
|
|
46
|
+
capsule,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validEnvelope(value, key) {
|
|
51
|
+
return Boolean(
|
|
52
|
+
value
|
|
53
|
+
&& value.schemaVersion === CONTEXT_CACHE_SCHEMA_VERSION
|
|
54
|
+
&& value.key === key
|
|
55
|
+
&& value.capsuleDigest === value.capsule?.digest
|
|
56
|
+
&& verifyContextCapsule(value.capsule),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readContextCache(cacheDir, key) {
|
|
61
|
+
const normalizedKey = assertCacheKey(key);
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(await fs.readFile(contextCachePath(cacheDir, normalizedKey), 'utf8'));
|
|
64
|
+
return validEnvelope(parsed, normalizedKey) ? parsed.capsule : null;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeContextCache(cacheDir, key, capsule) {
|
|
72
|
+
const normalizedKey = assertCacheKey(key);
|
|
73
|
+
const target = contextCachePath(cacheDir, normalizedKey);
|
|
74
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
75
|
+
const envelope = cacheEnvelope(normalizedKey, capsule);
|
|
76
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
77
|
+
try {
|
|
78
|
+
await fs.writeFile(temporary, `${JSON.stringify(envelope)}\n`, 'utf8');
|
|
79
|
+
await fs.rename(temporary, target);
|
|
80
|
+
} finally {
|
|
81
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeInvalidationSelector(selector = {}) {
|
|
87
|
+
const keys = new Set();
|
|
88
|
+
if (selector.key) keys.add(assertCacheKey(selector.key));
|
|
89
|
+
for (const key of selector.keys ?? []) keys.add(assertCacheKey(key));
|
|
90
|
+
return {
|
|
91
|
+
keys,
|
|
92
|
+
revision: selector.revision == null ? null : String(selector.revision),
|
|
93
|
+
all: selector.all === true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function invalidateContextCache(cacheDir, selector = {}) {
|
|
98
|
+
const normalized = normalizeInvalidationSelector(selector);
|
|
99
|
+
let names;
|
|
100
|
+
try {
|
|
101
|
+
names = await fs.readdir(cacheDir);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code === 'ENOENT') return { removed: [], kept: [], errors: [] };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const removed = [];
|
|
107
|
+
const kept = [];
|
|
108
|
+
const errors = [];
|
|
109
|
+
for (const name of names.filter((item) => /^context-v1-[a-f0-9]{64}\.json$/.test(item)).sort()) {
|
|
110
|
+
const key = name.slice(0, -'.json'.length);
|
|
111
|
+
let shouldRemove = normalized.all || normalized.keys.has(key);
|
|
112
|
+
if (!shouldRemove && normalized.revision != null) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(await fs.readFile(path.join(cacheDir, name), 'utf8'));
|
|
115
|
+
shouldRemove = parsed.revision === normalized.revision;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
errors.push({ key, error: error?.code ?? error?.name ?? 'read-error' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!shouldRemove) {
|
|
121
|
+
kept.push(key);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await fs.rm(path.join(cacheDir, name), { force: true });
|
|
126
|
+
removed.push(key);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
errors.push({ key, error: error?.code ?? error?.name ?? 'remove-error' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { removed, kept, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createMemoryContextCache() {
|
|
135
|
+
const records = new Map();
|
|
136
|
+
const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
|
|
137
|
+
return {
|
|
138
|
+
async read(key) {
|
|
139
|
+
const normalizedKey = assertCacheKey(key);
|
|
140
|
+
counters.reads += 1;
|
|
141
|
+
if (!records.has(normalizedKey)) {
|
|
142
|
+
counters.misses += 1;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
counters.hits += 1;
|
|
146
|
+
return cloneStable(records.get(normalizedKey).capsule);
|
|
147
|
+
},
|
|
148
|
+
async write(key, capsule) {
|
|
149
|
+
const normalizedKey = assertCacheKey(key);
|
|
150
|
+
records.set(normalizedKey, cacheEnvelope(normalizedKey, cloneStable(capsule)));
|
|
151
|
+
counters.writes += 1;
|
|
152
|
+
},
|
|
153
|
+
async invalidate(selector = {}) {
|
|
154
|
+
const normalized = normalizeInvalidationSelector(selector);
|
|
155
|
+
const removed = [];
|
|
156
|
+
const kept = [];
|
|
157
|
+
for (const [key, envelope] of [...records.entries()].sort(([left], [right]) => compareStableText(left, right))) {
|
|
158
|
+
const shouldRemove = normalized.all
|
|
159
|
+
|| normalized.keys.has(key)
|
|
160
|
+
|| (normalized.revision != null && envelope.revision === normalized.revision);
|
|
161
|
+
if (shouldRemove) {
|
|
162
|
+
records.delete(key);
|
|
163
|
+
removed.push(key);
|
|
164
|
+
} else {
|
|
165
|
+
kept.push(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
counters.invalidations += removed.length;
|
|
169
|
+
return { removed, kept, errors: [] };
|
|
170
|
+
},
|
|
171
|
+
stats() {
|
|
172
|
+
return { ...counters, size: records.size };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createFileContextCache(cacheDir) {
|
|
178
|
+
const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
|
|
179
|
+
return {
|
|
180
|
+
async read(key) {
|
|
181
|
+
counters.reads += 1;
|
|
182
|
+
const capsule = await readContextCache(cacheDir, key);
|
|
183
|
+
if (capsule) counters.hits += 1;
|
|
184
|
+
else counters.misses += 1;
|
|
185
|
+
return capsule;
|
|
186
|
+
},
|
|
187
|
+
async write(key, capsule) {
|
|
188
|
+
counters.writes += 1;
|
|
189
|
+
return writeContextCache(cacheDir, key, capsule);
|
|
190
|
+
},
|
|
191
|
+
async invalidate(selector = {}) {
|
|
192
|
+
const result = await invalidateContextCache(cacheDir, selector);
|
|
193
|
+
counters.invalidations += result.removed.length;
|
|
194
|
+
return result;
|
|
195
|
+
},
|
|
196
|
+
stats() {
|
|
197
|
+
return { ...counters };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function compileContextWithCache(input, {
|
|
203
|
+
cache,
|
|
204
|
+
previousCapsule = null,
|
|
205
|
+
} = {}) {
|
|
206
|
+
if (!cache || typeof cache.read !== 'function' || typeof cache.write !== 'function') {
|
|
207
|
+
throw new TypeError('compileContextWithCache requires a cache with read() and write().');
|
|
208
|
+
}
|
|
209
|
+
const cacheKey = buildContextCacheKey(input, { previousCapsule });
|
|
210
|
+
const cached = await cache.read(cacheKey);
|
|
211
|
+
if (cached && verifyContextCapsule(cached)) {
|
|
212
|
+
return { capsule: cached, cacheKey, cacheStatus: 'hit', compiled: false };
|
|
213
|
+
}
|
|
214
|
+
const capsule = compileContextCapsule(input, { previousCapsule });
|
|
215
|
+
await cache.write(cacheKey, capsule);
|
|
216
|
+
return { capsule, cacheKey, cacheStatus: 'miss', compiled: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A structural cold/warm benchmark. It deliberately reports operation counts
|
|
221
|
+
* rather than wall-clock thresholds, which keeps CI stable across machines.
|
|
222
|
+
*/
|
|
223
|
+
export async function runContextCacheBenchmark(input, {
|
|
224
|
+
cache = createMemoryContextCache(),
|
|
225
|
+
previousCapsule = null,
|
|
226
|
+
} = {}) {
|
|
227
|
+
const cold = await compileContextWithCache(input, { cache, previousCapsule });
|
|
228
|
+
const warm = await compileContextWithCache(input, { cache, previousCapsule });
|
|
229
|
+
return {
|
|
230
|
+
cold: {
|
|
231
|
+
cacheStatus: cold.cacheStatus,
|
|
232
|
+
compiled: cold.compiled,
|
|
233
|
+
digest: cold.capsule.digest,
|
|
234
|
+
includedCount: cold.capsule.entries.length,
|
|
235
|
+
},
|
|
236
|
+
warm: {
|
|
237
|
+
cacheStatus: warm.cacheStatus,
|
|
238
|
+
compiled: warm.compiled,
|
|
239
|
+
digest: warm.capsule.digest,
|
|
240
|
+
includedCount: warm.capsule.entries.length,
|
|
241
|
+
},
|
|
242
|
+
sameDigest: cold.capsule.digest === warm.capsule.digest,
|
|
243
|
+
cacheStats: typeof cache.stats === 'function' ? cache.stats() : null,
|
|
244
|
+
};
|
|
245
|
+
}
|