@zaip-ai/cli 4.4.0 → 4.6.0
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/README.md +2 -0
- package/dist/services/adapters.d.ts +1 -1
- package/dist/services/adapters.js +4 -4
- package/dist/services/adapters.js.map +1 -1
- package/dist/services/codex-conversation-lifecycle-hook.d.ts.map +1 -1
- package/dist/services/codex-conversation-lifecycle-hook.js +24 -5
- package/dist/services/codex-conversation-lifecycle-hook.js.map +1 -1
- package/dist/services/codex-native-tool-authority-template.d.ts.map +1 -1
- package/dist/services/codex-native-tool-authority-template.js +188 -9
- package/dist/services/codex-native-tool-authority-template.js.map +1 -1
- package/dist/services/codex-pre-tool-hook-template.d.ts.map +1 -1
- package/dist/services/codex-pre-tool-hook-template.js +50 -128
- package/dist/services/codex-pre-tool-hook-template.js.map +1 -1
- package/dist/services/codex-pre-tool-hook-template.test.js +4 -0
- package/dist/services/codex-pre-tool-hook-template.test.js.map +1 -1
- package/dist/services/codex-pre-tool-support-template.d.ts.map +1 -1
- package/dist/services/codex-pre-tool-support-template.js +1 -4
- package/dist/services/codex-pre-tool-support-template.js.map +1 -1
- package/dist/services/codex-stale-git-lock-template.d.ts +2 -0
- package/dist/services/codex-stale-git-lock-template.d.ts.map +1 -0
- package/dist/services/codex-stale-git-lock-template.js +64 -0
- package/dist/services/codex-stale-git-lock-template.js.map +1 -0
- package/dist/services/global-codex-config-migration.test.js +1 -4
- package/dist/services/global-codex-config-migration.test.js.map +1 -1
- package/dist/services/install-template-policy.d.ts.map +1 -1
- package/dist/services/install-template-policy.js +2 -0
- package/dist/services/install-template-policy.js.map +1 -1
- package/dist/services/logger.js +2 -1
- package/dist/services/logger.js.map +1 -1
- package/dist/services/logger.test.js +71 -0
- package/dist/services/logger.test.js.map +1 -1
- package/dist/services/runtime-template-test-support.d.ts +3 -0
- package/dist/services/runtime-template-test-support.d.ts.map +1 -0
- package/dist/services/runtime-template-test-support.js +32 -0
- package/dist/services/runtime-template-test-support.js.map +1 -0
- package/dist/services/runtime-template.test.js +23 -31
- package/dist/services/runtime-template.test.js.map +1 -1
- package/package.json +1 -1
- package/templates/.agents/README.md +8 -0
- package/templates/.agents/config/agents/discovery.json +1 -1
- package/templates/.agents/config/deploy-policy.json +5 -0
- package/templates/.agents/dashboard/.zaip-managed.json +211 -75
- package/templates/.agents/dashboard/README.md +2 -0
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{CostCalculationGuidePage-f4ZGJRg3.js → CostCalculationGuidePage-BRtiPijr.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{DashboardHelpDialog-CkQ_y8kg.js → DashboardHelpDialog-BR4ZeYpE.js} +99 -10
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{PricingSettingsDialog-v9zGRTlk.js → PricingSettingsDialog-Dr4QDFmn.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{ProjectSettingsPage-doIMhei7.js → ProjectSettingsPage-BG0N5dw_.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{TraceabilityWorkspace-C_4ftjyB.js → TraceabilityWorkspace-BvoM-YQ0.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/cost-monitor-fTQhSL85.js +68 -0
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{icons-vendor-Cv3WBFIF.js → icons-vendor-Bc6vux5t.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/index-BMfzKaAR.js +2 -0
- package/templates/.agents/dashboard/apps/frontend/dist/assets/index-DZhppOIk.css +1 -0
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{react-vendor-mHgsmxb6.js → react-vendor-DNJQFov3.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/{traceability-B0CPyEVq.js → traceability-B7tjIB7W.js} +1 -1
- package/templates/.agents/dashboard/apps/frontend/dist/index.html +6 -6
- package/templates/.agents/dashboard/apps/frontend/package.json +1 -1
- package/templates/.agents/dashboard/apps/frontend/src/AppOverlays.tsx +6 -34
- package/templates/.agents/dashboard/apps/frontend/src/ReconciliationFeedback.dom.test.ts +130 -0
- package/templates/.agents/dashboard/apps/frontend/src/ReconciliationFeedback.tsx +226 -0
- package/templates/.agents/dashboard/apps/frontend/src/dashboard-api-sqlite-view.ts +45 -0
- package/templates/.agents/dashboard/apps/frontend/src/dashboard-api.test.ts +15 -0
- package/templates/.agents/dashboard/apps/frontend/src/dashboard-api.ts +17 -46
- package/templates/.agents/dashboard/apps/frontend/src/reconciliation-feedback-fixtures.ts +23 -0
- package/templates/.agents/dashboard/apps/frontend/src/reconciliation-feedback-model.test.ts +69 -0
- package/templates/.agents/dashboard/apps/frontend/src/reconciliation-feedback-model.ts +59 -0
- package/templates/.agents/dashboard/apps/frontend/src/reconciliation-feedback.css +131 -0
- package/templates/.agents/dashboard/apps/frontend/src/styles.css +0 -32
- package/templates/.agents/dashboard/apps/frontend/src/use-dashboard-app-model.tsx +2 -2
- package/templates/.agents/dashboard/modules/cost-monitor/package.json +1 -1
- package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostMonitorPage.test.ts +2 -2
- package/templates/.agents/dashboard/modules/cost-monitor/src/react/CostMonitorPage.tsx +1 -1
- package/templates/.agents/dashboard/modules/operations/package.json +1 -1
- package/templates/.agents/dashboard/modules/operations/src/react/OperationsPage.tsx +1 -4
- package/templates/.agents/dashboard/modules/operations/src/react/UnifiedWorkBoard.navigation.test.ts +236 -0
- package/templates/.agents/dashboard/modules/operations/src/react/UnifiedWorkBoard.tsx +1 -24
- package/templates/.agents/dashboard/modules/operations/src/react/operations-overview-period-filter.test.ts +3 -2
- package/templates/.agents/dashboard/modules/traceability/package.json +1 -1
- package/templates/.agents/dashboard/packages/branding/package.json +1 -1
- package/templates/.agents/dashboard/packages/contracts/package.json +1 -1
- package/templates/.agents/dashboard/packages/data-export/package.json +1 -1
- package/templates/.agents/dashboard/packages/local-runtime/DIAGNOSTICS.md +42 -0
- package/templates/.agents/dashboard/packages/local-runtime/dashboard-diagnostics.d.mts +10 -0
- package/templates/.agents/dashboard/packages/local-runtime/dashboard-diagnostics.mjs +101 -0
- package/templates/.agents/dashboard/packages/local-runtime/dashboard-diagnostics.test.mjs +58 -0
- package/templates/.agents/dashboard/packages/local-runtime/dist/server.cjs +232 -217
- package/templates/.agents/dashboard/packages/local-runtime/dist/sqlite-read-model.cjs +201 -188
- package/templates/.agents/dashboard/packages/local-runtime/package.json +1 -1
- package/templates/.agents/dashboard/packages/local-runtime/scripts/test-sqlite-dashboard-startup.mjs +233 -0
- package/templates/.agents/dashboard/packages/local-runtime/scripts/validate-provider-nonzero.mjs +45 -0
- package/templates/.agents/dashboard/packages/local-runtime/scripts/verify-dashboard-executable-startup.mjs +107 -0
- package/templates/.agents/dashboard/packages/local-runtime/scripts/verify-dashboard-reconciliation-executable.mjs +271 -0
- package/templates/.agents/dashboard/packages/local-runtime/server.mjs +232 -217
- package/templates/.agents/dashboard/packages/local-runtime/src/active-codex-telemetry.test.ts +78 -12
- package/templates/.agents/dashboard/packages/local-runtime/src/active-codex-telemetry.ts +36 -56
- package/templates/.agents/dashboard/packages/local-runtime/src/canonical-source-revision-cache.test.ts +125 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/canonical-source-revision-cache.ts +168 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/dashboard-diagnostics.test.ts +57 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-agent-catalog-routes.ts +76 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-dual-reads-manifest-summaries-without-losing.test.ts +5 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-reconciliation-diagnostics.test.ts +118 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-reconciliation-result.test.ts +35 -6
- package/templates/.agents/dashboard/packages/local-runtime/src/server-reconciliation-route.ts +105 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-snapshot-synchronization.test.ts +64 -9
- package/templates/.agents/dashboard/packages/local-runtime/src/server-snapshot-synchronization.ts +19 -27
- package/templates/.agents/dashboard/packages/local-runtime/src/server-telemetry-command.test.ts +111 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-telemetry-refresh.test.ts +59 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-telemetry-refresh.ts +44 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-work-reconciliation.test.ts +20 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/server-work-reconciliation.ts +12 -7
- package/templates/.agents/dashboard/packages/local-runtime/src/server-workspace.ts +48 -6
- package/templates/.agents/dashboard/packages/local-runtime/src/server.test.ts +2 -2
- package/templates/.agents/dashboard/packages/local-runtime/src/server.ts +126 -134
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-cost-agent-page.ts +14 -2
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-dashboard-startup.test.ts +163 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-dashboard-startup.ts +78 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-header-timestamp.test.ts +31 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-provider-nonzero.test.ts +271 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-provider-visibility.ts +32 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-query-operations-duration.ts +58 -0
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-query-provider-dimension.ts +12 -3
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-query-view-page.ts +9 -58
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model-rebuild.ts +9 -35
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model-schema.ts +10 -8
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model-source-fingerprint.ts +43 -45
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model.test.ts +5 -9
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-read-model.ts +5 -1
- package/templates/.agents/dashboard/packages/local-runtime/src/sqlite-startup-seed.test.ts +87 -0
- package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-logging.mjs +6 -2
- package/templates/.agents/dashboard/packages/local-runtime/start-dashboard-server.mjs +7 -1
- package/templates/.agents/dashboard/packages/local-runtime/start-dashboard.mjs +4 -5
- package/templates/.agents/dashboard/packages/ui/package.json +1 -1
- package/templates/.agents/dashboard/packages/ui/src/DashboardHeader.test.mjs +33 -4
- package/templates/.agents/dashboard/packages/ui/src/DashboardHeader.tsx +8 -7
- package/templates/.agents/dashboard/packages/windows-launcher/Program.cs +46 -3
- package/templates/.agents/dashboard/tests/e2e/package.json +1 -1
- package/templates/.agents/dashboard/tests/e2e/qualify-kanban-popup.mjs +56 -0
- package/templates/.agents/dashboard/tests/e2e/specs/dashboard-update-label.spec.ts +49 -0
- package/templates/.agents/design/.zaip-managed.json +17 -5
- package/templates/.agents/design/apps/catalog/dist/assets/index-D3uhpycC.js +72 -0
- package/templates/.agents/design/apps/catalog/dist/index.html +1 -1
- package/templates/.agents/design/apps/catalog/package.json +1 -1
- package/templates/.agents/design/apps/catalog/src/pages/ComponentsPage.tsx +13 -1
- package/templates/.agents/design/apps/catalog/src/specimens/ReconciliationSpecimen.stories.tsx +6 -0
- package/templates/.agents/design/apps/catalog/src/specimens/ReconciliationSpecimen.tsx +101 -0
- package/templates/.agents/design/packages/local-runtime/package.json +1 -1
- package/templates/.agents/requirements/autonomous-assurance.md +1 -1
- package/templates/.agents/requirements/cloud-hostinger-autonomy.md +1 -1
- package/templates/.agents/requirements/comparative-audit.md +1 -1
- package/templates/.agents/requirements/context-engine.md +1 -1
- package/templates/.agents/requirements/cost-observability.md +1 -1
- package/templates/.agents/requirements/dashboard-launch.md +1 -1
- package/templates/.agents/requirements/data-export.md +1 -1
- package/templates/.agents/requirements/design-system.md +1 -1
- package/templates/.agents/requirements/execution-engine.md +1 -1
- package/templates/.agents/requirements/kanban-dashboard.md +1 -1
- package/templates/.agents/requirements/model-orchestration.md +21 -6
- package/templates/.agents/requirements/public-distribution.md +4 -4
- package/templates/.agents/requirements/software-architecture.md +1 -1
- package/templates/.agents/requirements/strategic-improvements.md +1 -1
- package/templates/.agents/requirements/work-lifecycle.md +1 -1
- package/templates/.agents/requirements/zaip-strategic-95.md +1 -1
- package/templates/.agents/rules/core.md +3 -1
- package/templates/.agents/rules/workflow.md +3 -2
- package/templates/.agents/runtime/VERSION +1 -1
- package/templates/.agents/runtime/bin/zaip.mjs +705 -516
- package/templates/.agents/runtime/checksums.sha256 +5 -5
- package/templates/.agents/runtime/manifest.json +1 -1
- package/templates/.agents/runtime/provenance.json +4 -4
- package/templates/.agents/runtime/sbom.cdx.json +1 -1
- package/templates/.agents/skills/zaip-skill-config/SKILL.md +25 -3
- package/templates/.agents/skills/zaip-skill-execution/SKILL.md +4 -0
- package/templates/.agents/skills/zaip-skill-execution/references/autonomous-continuation.md +4 -0
- package/templates/.agents/skills/zaip-skill-execution/references/candidate-workflow.md +1 -1
- package/templates/.agents/skills/zaip-skill-execution/references/runtime-governance.md +2 -2
- package/templates/.agents/skills/zaip-skill-general-operations/SKILL.md +1 -0
- package/templates/.agents/skills/zaip-skill-github-deploy/SKILL.md +36 -48
- package/templates/.agents/skills/zaip-skill-github-deploy/agents/openai.yaml +2 -2
- package/templates/.agents/skills/zaip-skill-github-deploy/references/deployment-workflow.md +67 -99
- package/templates/.agents/skills/zaip-skill-github-deploy/references/release-identity.md +33 -30
- package/templates/.agents/skills/zaip-skill-github-deploy/scripts/deploy-contract.mjs +303 -0
- package/templates/.agents/skills/zaip-skill-github-deploy/scripts/deploy-contract.test.mjs +334 -0
- package/templates/.agents/skills/zaip-skill-github-deploy/scripts/github-deploy-contract.test.mjs +41 -28
- package/templates/.agents/skills/zaip-skill-github-deploy/scripts/guarded-commits.mjs +212 -0
- package/templates/.agents/skills/zaip-skill-lifecycle-closure/SKILL.md +4 -0
- package/templates/.agents/skills/zaip-skill-prd/SKILL.md +4 -0
- package/templates/.agents/skills/zaip-skill-release-qualification/SKILL.md +44 -68
- package/templates/.agents/skills/zaip-skill-release-qualification/references/local-preparation.md +91 -44
- package/templates/.agents/skills/zaip-skill-send-email/SKILL.md +15 -0
- package/templates/.agents/skills/zaip-skill-send-email/agents/openai.yaml +4 -0
- package/templates/.agents/skills/zaip-skill-setup/SKILL.md +5 -2
- package/templates/.agents/skills/zaip-skill-spec/SKILL.md +4 -0
- package/templates/.agents/skills/zaip-skill-task/SKILL.md +4 -0
- package/templates/.agents/skills/zaip-skill-testing-strategy/SKILL.md +19 -0
- package/templates/.agents/skills/zaip-skill-tool-setup/SKILL.md +2 -0
- package/templates/.agents/skills/zaip-skill-use-connectors/SKILL.md +15 -0
- package/templates/.agents/skills/zaip-skill-use-connectors/agents/openai.yaml +4 -0
- package/templates/.agents/skills/zaip-skill-use-connectors/scripts/connector-guidance.test.mjs +29 -0
- package/templates/.agents/skills/zaip-skill-use-connectors/scripts/verify-connector-lock.mjs +30 -0
- package/templates/.agents/tools/cloud-runner.integration.test.mjs +3 -0
- package/templates/.agents/zaip-aios/VERSION +1 -1
- package/templates/.agents/zaip-aios/guides/03-tasks.md +6 -0
- package/templates/.agents/zaip-aios/guides/04-specs.md +6 -0
- package/templates/.agents/zaip-aios/guides/05-prds.md +4 -0
- package/templates/.agents/zaip-aios/guides/06-execucao.md +6 -0
- package/templates/.agents/zaip-aios/guides/16-lifecycle-e-reconciliacao.md +6 -0
- package/templates/.agents/zaip-aios/guides/19-assurance-e-autonomia.md +4 -0
- package/templates/.agents/zaip-aios/guides/22-fluxos-e-roteamento.md +6 -0
- package/templates/.agents/zaip-aios/guides/23-orquestracao-de-modelos.md +36 -4
- package/templates/.agents/zaip-aios/guides/26-qualidade-e-release.md +15 -4
- package/templates/.agents/zaip-aios/guides/28-lifecycle-completo.md +8 -0
- package/templates/template-manifest.json +330 -142
- package/templates/.agents/dashboard/apps/frontend/dist/assets/cost-monitor-D7PrDZy9.js +0 -68
- package/templates/.agents/dashboard/apps/frontend/dist/assets/index-OBJXZOwe.css +0 -1
- package/templates/.agents/dashboard/apps/frontend/dist/assets/index-Rbmpz4UO.js +0 -2
package/templates/.agents/dashboard/packages/local-runtime/src/active-codex-telemetry.test.ts
CHANGED
|
@@ -1,33 +1,84 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
captureActiveCodexTelemetry,
|
|
8
|
+
captureRepositoryActiveCodexTelemetry,
|
|
9
|
+
} from "./active-codex-telemetry.js";
|
|
10
|
+
import * as sources from "./sqlite-read-model-source-fingerprint.js";
|
|
7
11
|
|
|
8
12
|
const roots: string[] = [];
|
|
9
13
|
|
|
10
14
|
afterEach(async () => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
11
16
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
12
17
|
});
|
|
13
18
|
|
|
14
19
|
describe("active Codex telemetry capture", () => {
|
|
20
|
+
it("allows the measured catch-up budget and still rejects incomplete output", async () => {
|
|
21
|
+
const root = await runtimeFixture("");
|
|
22
|
+
const execute = vi.fn(async (_file: string, _args: string[], _options: unknown) => ({
|
|
23
|
+
stdout: JSON.stringify({
|
|
24
|
+
telemetry: { mode: "recent_exact_session", complete: false, captured: [] },
|
|
25
|
+
}),
|
|
26
|
+
stderr: "",
|
|
27
|
+
}));
|
|
28
|
+
await expect(captureActiveCodexTelemetry(root, root, execute as never)).rejects.toThrow(
|
|
29
|
+
"incomplete",
|
|
30
|
+
);
|
|
31
|
+
expect(execute.mock.calls[0]?.[2]).toMatchObject({
|
|
32
|
+
timeout: 600_000,
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
cwd: root,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it("uses the primary CLI for registered worktrees while preserving each target workspace and cwd", async () => {
|
|
38
|
+
const primary = await runtimeFixture(`import {writeFileSync} from 'node:fs';
|
|
39
|
+
import {resolve} from 'node:path';
|
|
40
|
+
const target=process.argv[process.argv.indexOf('--workspace')+1];
|
|
41
|
+
if(target!==resolve(process.cwd()))throw new Error('Target workspace mismatch');
|
|
42
|
+
writeFileSync('capture-target.json',JSON.stringify({target,cwd:process.cwd(),cli:process.argv[1]}));
|
|
43
|
+
process.stdout.write(JSON.stringify({telemetry:{mode:'recent_exact_session',complete:true,captured:[{captured:true}]}}));`);
|
|
44
|
+
const secondary = await runtimeFixture(`import {writeFileSync} from 'node:fs';
|
|
45
|
+
writeFileSync('outdated-cli-executed','incorrect runtime');process.exitCode=9;`);
|
|
46
|
+
vi.spyOn(sources, "dashboardCanonicalSourceRoots").mockResolvedValue([primary, secondary]);
|
|
47
|
+
await expect(captureRepositoryActiveCodexTelemetry(primary)).resolves.toMatchObject({
|
|
48
|
+
captured_runs: 2,
|
|
49
|
+
captured_segments: null,
|
|
50
|
+
skipped_reason: null,
|
|
51
|
+
});
|
|
52
|
+
for (const target of [primary, secondary]) {
|
|
53
|
+
const captured = JSON.parse(await readFile(join(target, "capture-target.json"), "utf8"));
|
|
54
|
+
expect(captured).toEqual({
|
|
55
|
+
target,
|
|
56
|
+
cwd: target,
|
|
57
|
+
cli: join(primary, ".agents/runtime/bin/zaip.mjs"),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
await expect(stat(join(secondary, "outdated-cli-executed"))).rejects.toMatchObject({
|
|
61
|
+
code: "ENOENT",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
15
64
|
it("reports an idle capture explicitly instead of treating it as an opaque success", async () => {
|
|
16
65
|
const root = await runtimeFixture(
|
|
17
|
-
"process.stdout.write(JSON.stringify({ telemetry: { mode: '
|
|
66
|
+
"process.stdout.write(JSON.stringify({ telemetry: { mode: 'recent_exact_session', complete:true, captured: [] } }, null, 2));\n",
|
|
18
67
|
);
|
|
19
68
|
|
|
20
69
|
await expect(captureActiveCodexTelemetry(root)).resolves.toMatchObject({
|
|
21
70
|
captured_runs: 0,
|
|
22
71
|
captured_segments: 0,
|
|
23
72
|
manifest_state: "initialized",
|
|
24
|
-
skipped_reason: "
|
|
73
|
+
skipped_reason: "no_recent_runs",
|
|
25
74
|
last_capture_at: expect.any(String),
|
|
26
75
|
});
|
|
27
76
|
});
|
|
28
77
|
|
|
29
|
-
it("does not
|
|
30
|
-
const root = await runtimeFixture(
|
|
78
|
+
it("does not let an empty or stale active index suppress terminal usage capture", async () => {
|
|
79
|
+
const root = await runtimeFixture(
|
|
80
|
+
"if(!process.argv.includes('--recent') || !process.argv.includes('--drain') || process.argv.includes('--active') || process.argv[process.argv.indexOf('--limit')+1]!=='32') process.exit(8); process.stdout.write(JSON.stringify({telemetry:{mode:'recent_exact_session',complete:true,captured:[{captured:true}]}}));\n",
|
|
81
|
+
);
|
|
31
82
|
await writeFile(
|
|
32
83
|
join(root, ".agents/records/lifecycle/active-work.json"),
|
|
33
84
|
JSON.stringify({
|
|
@@ -36,13 +87,13 @@ describe("active Codex telemetry capture", () => {
|
|
|
36
87
|
}),
|
|
37
88
|
);
|
|
38
89
|
await expect(captureActiveCodexTelemetry(root)).resolves.toMatchObject({
|
|
39
|
-
skipped_reason:
|
|
40
|
-
captured_runs:
|
|
90
|
+
skipped_reason: null,
|
|
91
|
+
captured_runs: 1,
|
|
41
92
|
});
|
|
42
93
|
});
|
|
43
94
|
it("still captures a recently active worktree Run", async () => {
|
|
44
95
|
const root = await runtimeFixture(
|
|
45
|
-
"process.stdout.write(JSON.stringify({telemetry:{captured:[{captured:true}]}}));\n",
|
|
96
|
+
"process.stdout.write(JSON.stringify({telemetry:{mode:'recent_exact_session',complete:true,captured:[{captured:true}]}}));\n",
|
|
46
97
|
);
|
|
47
98
|
await writeFile(
|
|
48
99
|
join(root, ".agents/records/lifecycle/active-work.json"),
|
|
@@ -71,7 +122,7 @@ describe("active Codex telemetry capture", () => {
|
|
|
71
122
|
});
|
|
72
123
|
it("does not accept CLI idle success as proof of initialized lifecycle", async () => {
|
|
73
124
|
const root = await runtimeFixture(
|
|
74
|
-
"process.stdout.write(JSON.stringify({ telemetry: { captured: [] } }));",
|
|
125
|
+
"process.stdout.write(JSON.stringify({ telemetry: { mode:'recent_exact_session', complete:true, captured: [] } }));",
|
|
75
126
|
);
|
|
76
127
|
await rm(join(root, ".agents/records/lifecycle"), { recursive: true });
|
|
77
128
|
await expect(captureActiveCodexTelemetry(root)).resolves.toMatchObject({
|
|
@@ -81,7 +132,7 @@ describe("active Codex telemetry capture", () => {
|
|
|
81
132
|
});
|
|
82
133
|
it("preserves unavailable segment cardinality for a captured Run", async () => {
|
|
83
134
|
const root = await runtimeFixture(
|
|
84
|
-
"process.stdout.write(JSON.stringify({ telemetry: { captured: [{captured:true}] } }, null, 2));",
|
|
135
|
+
"process.stdout.write(JSON.stringify({ telemetry: { mode:'recent_exact_session', complete:true, captured: [{captured:true}] } }, null, 2));",
|
|
85
136
|
);
|
|
86
137
|
await expect(captureActiveCodexTelemetry(root)).resolves.toMatchObject({
|
|
87
138
|
captured_runs: 1,
|
|
@@ -95,6 +146,21 @@ describe("active Codex telemetry capture", () => {
|
|
|
95
146
|
);
|
|
96
147
|
await expect(captureActiveCodexTelemetry(root)).rejects.toThrow("Invalid capture telemetry");
|
|
97
148
|
});
|
|
149
|
+
it("rejects an incomplete bounded pass rather than calling partial collection current", async () => {
|
|
150
|
+
const root = await runtimeFixture(
|
|
151
|
+
"process.stdout.write(JSON.stringify({telemetry:{mode:'recent_exact_session',complete:false,captured:[]}}));",
|
|
152
|
+
);
|
|
153
|
+
await expect(captureActiveCodexTelemetry(root)).rejects.toThrow("incomplete");
|
|
154
|
+
});
|
|
155
|
+
it.each([
|
|
156
|
+
"active_exact_session",
|
|
157
|
+
null,
|
|
158
|
+
])("rejects incompatible capture receipt mode %s", async (mode) => {
|
|
159
|
+
const root = await runtimeFixture(
|
|
160
|
+
`process.stdout.write(JSON.stringify({telemetry:{mode:${JSON.stringify(mode)},complete:true,captured:[]}}));`,
|
|
161
|
+
);
|
|
162
|
+
await expect(captureActiveCodexTelemetry(root)).rejects.toThrow("incomplete");
|
|
163
|
+
});
|
|
98
164
|
});
|
|
99
165
|
|
|
100
166
|
async function runtimeFixture(script: string): Promise<string> {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { resolve } from "node:path";
|
|
4
3
|
import { promisify } from "node:util";
|
|
5
4
|
import { WorkspaceFileSystem } from "@zaip-ai/core";
|
|
6
5
|
import { lifecycleManifestState } from "./server-lifecycle-diagnostics.js";
|
|
7
6
|
import { dashboardCanonicalSourceRoots } from "./sqlite-read-model-source-fingerprint.js";
|
|
8
7
|
|
|
9
8
|
const execFileAsync = promisify(execFile);
|
|
9
|
+
// Measured complete catch-up of a 98-Run / 30k-envelope backlog exceeded the
|
|
10
|
+
// old 55s deadline. Keep the UI updating while this finite selection commits;
|
|
11
|
+
// expiry remains a failure and can never promote a partial capture to current.
|
|
12
|
+
export const RECENT_CODEX_CAPTURE_TIMEOUT_MS = 600_000;
|
|
10
13
|
|
|
11
14
|
export type ActiveCodexTelemetryCapture = {
|
|
12
15
|
captured_runs: number;
|
|
@@ -18,18 +21,21 @@ export type ActiveCodexTelemetryCapture = {
|
|
|
18
21
|
| "manifest_invalid"
|
|
19
22
|
| "no_active_runs"
|
|
20
23
|
| "no_recent_active_runs"
|
|
24
|
+
| "no_recent_runs"
|
|
21
25
|
| "no_new_segments"
|
|
22
26
|
| null;
|
|
23
27
|
last_capture_at: string;
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
|
-
* Captures
|
|
31
|
+
* Captures recent active and terminal Runs whose persisted Codex correlation names a concrete
|
|
28
32
|
* turn. The CLI reuses source segment and loop keys as its durable cursor;
|
|
29
33
|
* this never reconciles or changes a Run lifecycle.
|
|
30
34
|
*/
|
|
31
35
|
export async function captureActiveCodexTelemetry(
|
|
32
36
|
workspaceRoot: string,
|
|
37
|
+
runtimeRoot = workspaceRoot,
|
|
38
|
+
execute = execFileAsync,
|
|
33
39
|
): Promise<ActiveCodexTelemetryCapture> {
|
|
34
40
|
// The CLI intentionally treats an absent active-work index as an empty pass.
|
|
35
41
|
// Validate the canonical manifest first so that exit 0 cannot mask missing lifecycle.
|
|
@@ -42,26 +48,28 @@ export async function captureActiveCodexTelemetry(
|
|
|
42
48
|
skipped_reason: manifestState === "missing" ? "manifest_uninitialized" : "manifest_invalid",
|
|
43
49
|
last_capture_at: new Date().toISOString(),
|
|
44
50
|
};
|
|
45
|
-
const
|
|
46
|
-
if (activeRuns !== null && activeRuns.recent === 0) {
|
|
47
|
-
return {
|
|
48
|
-
captured_runs: 0,
|
|
49
|
-
captured_segments: 0,
|
|
50
|
-
manifest_state: "initialized",
|
|
51
|
-
skipped_reason: activeRuns.stale > 0 ? "no_recent_active_runs" : "no_active_runs",
|
|
52
|
-
last_capture_at: new Date().toISOString(),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
const cliPath = resolve(workspaceRoot, ".agents", "runtime", "bin", "zaip.mjs");
|
|
51
|
+
const cliPath = resolve(runtimeRoot, ".agents", "runtime", "bin", "zaip.mjs");
|
|
56
52
|
try {
|
|
57
|
-
const outcome = await
|
|
53
|
+
const outcome = await execute(
|
|
58
54
|
process.execPath,
|
|
59
|
-
[
|
|
55
|
+
[
|
|
56
|
+
cliPath,
|
|
57
|
+
"--workspace",
|
|
58
|
+
resolve(workspaceRoot),
|
|
59
|
+
"run",
|
|
60
|
+
"usage",
|
|
61
|
+
"--recent",
|
|
62
|
+
"--limit",
|
|
63
|
+
"32",
|
|
64
|
+
"--drain",
|
|
65
|
+
"--disable-codex-raw",
|
|
66
|
+
"--json",
|
|
67
|
+
],
|
|
60
68
|
{
|
|
61
69
|
cwd: workspaceRoot,
|
|
62
70
|
encoding: "utf8",
|
|
63
71
|
windowsHide: true,
|
|
64
|
-
timeout:
|
|
72
|
+
timeout: RECENT_CODEX_CAPTURE_TIMEOUT_MS,
|
|
65
73
|
maxBuffer: 1_024 * 1_024,
|
|
66
74
|
},
|
|
67
75
|
);
|
|
@@ -75,7 +83,7 @@ export async function captureActiveCodexTelemetry(
|
|
|
75
83
|
captured_segments: capturedRuns === 0 ? 0 : null,
|
|
76
84
|
manifest_state: "initialized",
|
|
77
85
|
skipped_reason:
|
|
78
|
-
captured.length === 0 ? "
|
|
86
|
+
captured.length === 0 ? "no_recent_runs" : capturedRuns === 0 ? "no_new_segments" : null,
|
|
79
87
|
last_capture_at: new Date().toISOString(),
|
|
80
88
|
};
|
|
81
89
|
} catch (error) {
|
|
@@ -92,49 +100,18 @@ export async function captureActiveCodexTelemetry(
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
/** A stale lifecycle index is not proof of a live Codex invocation. */
|
|
96
|
-
async function recentActiveRuns(
|
|
97
|
-
workspaceRoot: string,
|
|
98
|
-
): Promise<{ recent: number; stale: number } | null> {
|
|
99
|
-
const path = join(workspaceRoot, ".agents", "records", "lifecycle", "active-work.json");
|
|
100
|
-
const metadata = await stat(path).catch((error: NodeJS.ErrnoException) => {
|
|
101
|
-
if (error.code === "ENOENT") return null;
|
|
102
|
-
throw error;
|
|
103
|
-
});
|
|
104
|
-
if (metadata === null) return null;
|
|
105
|
-
if (metadata.size > 8 * 1024 * 1024) throw new Error("Active-work index exceeds capture limit");
|
|
106
|
-
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
107
|
-
if (
|
|
108
|
-
typeof parsed !== "object" ||
|
|
109
|
-
parsed === null ||
|
|
110
|
-
!Array.isArray((parsed as { items?: unknown }).items)
|
|
111
|
-
)
|
|
112
|
-
throw new Error("Invalid active-work index for telemetry capture");
|
|
113
|
-
const cutoff = Date.now() - 72 * 60 * 60 * 1_000;
|
|
114
|
-
const runs = (parsed as { items: unknown[] }).items.filter(
|
|
115
|
-
(item) =>
|
|
116
|
-
typeof item === "object" &&
|
|
117
|
-
item !== null &&
|
|
118
|
-
(item as { kind?: unknown }).kind === "run" &&
|
|
119
|
-
(item as { status?: unknown }).status === "running",
|
|
120
|
-
);
|
|
121
|
-
const recent = runs.filter((item) => {
|
|
122
|
-
const updated = (item as { updated_at?: unknown }).updated_at;
|
|
123
|
-
return (
|
|
124
|
-
typeof updated === "string" &&
|
|
125
|
-
Number.isFinite(Date.parse(updated)) &&
|
|
126
|
-
Date.parse(updated) >= cutoff
|
|
127
|
-
);
|
|
128
|
-
}).length;
|
|
129
|
-
return { recent, stale: runs.length - recent };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
103
|
/** Capture each canonical checkout before refreshing the combined SQLite view. */
|
|
133
104
|
export async function captureRepositoryActiveCodexTelemetry(
|
|
134
105
|
workspaceRoot: string,
|
|
106
|
+
runtimeRoot = workspaceRoot,
|
|
135
107
|
): Promise<ActiveCodexTelemetryCapture> {
|
|
136
108
|
const roots = await dashboardCanonicalSourceRoots(workspaceRoot);
|
|
137
|
-
|
|
109
|
+
// Registered checkouts contribute records, not executable runtime versions.
|
|
110
|
+
// One current CLI serves every target; --workspace pins its storage and
|
|
111
|
+
// exact source correlation even when the cwd belongs to another checkout.
|
|
112
|
+
const results = await Promise.all(
|
|
113
|
+
roots.map((root) => captureActiveCodexTelemetry(root, runtimeRoot)),
|
|
114
|
+
);
|
|
138
115
|
const primary = results[0];
|
|
139
116
|
if (primary === undefined) throw new Error("No dashboard workspace was discovered");
|
|
140
117
|
const capturedRuns = results.reduce((count, result) => count + result.captured_runs, 0);
|
|
@@ -166,6 +143,9 @@ function readCaptured(payload: unknown): Array<{ captured: boolean }> {
|
|
|
166
143
|
const telemetry = (payload as { telemetry?: unknown }).telemetry;
|
|
167
144
|
if (typeof telemetry !== "object" || telemetry === null)
|
|
168
145
|
throw new Error("Invalid capture telemetry");
|
|
146
|
+
const receipt = telemetry as { mode?: unknown; complete?: unknown };
|
|
147
|
+
if (receipt.mode !== "recent_exact_session" || receipt.complete !== true)
|
|
148
|
+
throw new Error("Recent telemetry capture is incomplete");
|
|
169
149
|
const captured = (telemetry as { captured?: unknown }).captured;
|
|
170
150
|
if (!Array.isArray(captured)) throw new Error("Invalid capture entries");
|
|
171
151
|
return captured.map((item) => {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { CanonicalSourceRevisionCache } from "./canonical-source-revision-cache.js";
|
|
5
|
+
import { sourceFingerprint } from "./sqlite-read-model-source-fingerprint.js";
|
|
6
|
+
|
|
7
|
+
vi.mock("node:fs/promises", async (original) => {
|
|
8
|
+
const actual = await original<typeof import("node:fs/promises")>();
|
|
9
|
+
return { ...actual, readFile: vi.fn(actual.readFile) };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const roots: string[] = [];
|
|
13
|
+
const source = ".agents/records/tasks/TASK-CACHE.md";
|
|
14
|
+
const record = (revision: number) =>
|
|
15
|
+
`---\nid: TASK-CACHE\nupdated_at: 2026-09-29T12:00:00Z\nrevision: ${revision}\n---\n`;
|
|
16
|
+
async function fixture() {
|
|
17
|
+
const parent = resolve(import.meta.dirname, "../../../../..", ".local/sqlite/tests");
|
|
18
|
+
await fs.mkdir(parent, { recursive: true });
|
|
19
|
+
const root = await fs.mkdtemp(join(parent, "revision-cache-"));
|
|
20
|
+
roots.push(root);
|
|
21
|
+
await fs.mkdir(join(root, ".agents/records/tasks"), { recursive: true });
|
|
22
|
+
await fs.writeFile(join(root, source), record(1));
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("physical canonical revision cache", () => {
|
|
31
|
+
it("persists peer revisions across workers, selects revision despite older mtime, and bypasses on force", async () => {
|
|
32
|
+
const root = await fixture();
|
|
33
|
+
const peer = await fixture();
|
|
34
|
+
await fs.writeFile(join(peer, source), record(2));
|
|
35
|
+
await fs.utimes(join(peer, source), new Date("2025-01-01"), new Date("2025-01-01"));
|
|
36
|
+
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 10_000);
|
|
37
|
+
const options = { sourceRoots: [root, peer] };
|
|
38
|
+
const initial = await sourceFingerprint(root, options);
|
|
39
|
+
expect(initial.candidates[0]?.path).toBe(join(peer, source));
|
|
40
|
+
expect(initial.duplicateReads).toBe(2);
|
|
41
|
+
const previous = new Map(initial.candidates.map((item) => [item.source, item]));
|
|
42
|
+
const next = await sourceFingerprint(root, { ...options, previous });
|
|
43
|
+
expect(next).toMatchObject({ duplicateReads: 0, duplicateReused: 2, hashedFiles: 0 });
|
|
44
|
+
expect(next.fingerprint).toBe(initial.fingerprint);
|
|
45
|
+
const forced = await sourceFingerprint(root, { ...options, previous, verifyContent: true });
|
|
46
|
+
expect(forced).toMatchObject({ duplicateReads: 2, duplicateReused: 0, hashedFiles: 1 });
|
|
47
|
+
expect(forced.fingerprint).toBe(initial.fingerprint);
|
|
48
|
+
expect(await fs.stat(join(peer, ".local")).catch(() => null)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("invalidates edits, replacements, additions and removals; keeps ties deterministic", async () => {
|
|
52
|
+
const root = await fixture();
|
|
53
|
+
const peer = await fixture();
|
|
54
|
+
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 10_000);
|
|
55
|
+
const options = { sourceRoots: [root, peer] };
|
|
56
|
+
expect((await sourceFingerprint(root, options)).candidates[0]?.path).toBe(join(root, source));
|
|
57
|
+
const metadata = await fs.stat(join(peer, source));
|
|
58
|
+
await fs.writeFile(join(peer, source), record(2));
|
|
59
|
+
await fs.utimes(join(peer, source), metadata.atime, metadata.mtime);
|
|
60
|
+
expect((await sourceFingerprint(root, options)).candidates[0]?.path).toBe(join(peer, source));
|
|
61
|
+
await fs.writeFile(join(root, "replacement"), record(3));
|
|
62
|
+
await fs.utimes(join(root, "replacement"), metadata.atime, metadata.mtime);
|
|
63
|
+
await fs.rename(join(root, "replacement"), join(root, source));
|
|
64
|
+
expect((await sourceFingerprint(root, options)).candidates[0]?.path).toBe(join(root, source));
|
|
65
|
+
await fs.writeFile(join(peer, ".agents/records/tasks/NEW.md"), record(1));
|
|
66
|
+
expect((await sourceFingerprint(root, options)).candidates).toHaveLength(2);
|
|
67
|
+
await fs.rm(join(root, source));
|
|
68
|
+
expect(
|
|
69
|
+
(await sourceFingerprint(root, options)).candidates.find((item) => item.source === source)
|
|
70
|
+
?.path,
|
|
71
|
+
).toBe(join(peer, source));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not trust rapid writes or their first settled cache entry", async () => {
|
|
75
|
+
const root = await fixture();
|
|
76
|
+
const peer = await fixture();
|
|
77
|
+
const metadata = await fs.stat(join(root, source));
|
|
78
|
+
const clock = vi.spyOn(Date, "now").mockReturnValue(metadata.ctimeMs);
|
|
79
|
+
const options = { sourceRoots: [root, peer] };
|
|
80
|
+
await sourceFingerprint(root, options);
|
|
81
|
+
for (let value = 2; value < 8; value++) {
|
|
82
|
+
await fs.writeFile(join(peer, source), record(value));
|
|
83
|
+
const result = await sourceFingerprint(root, options);
|
|
84
|
+
expect(result.duplicateReads).toBe(2);
|
|
85
|
+
expect(result.candidates[0]?.path).toBe(join(peer, source));
|
|
86
|
+
}
|
|
87
|
+
clock.mockReturnValue(Date.now() + 20_000);
|
|
88
|
+
expect((await sourceFingerprint(root, options)).duplicateReads).toBe(2);
|
|
89
|
+
expect((await sourceFingerprint(root, options)).duplicateReads).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("retries when file metadata changes while reading its revision", async () => {
|
|
93
|
+
const root = await fixture();
|
|
94
|
+
const cache = await CanonicalSourceRevisionCache.open(root);
|
|
95
|
+
const actualRead = (
|
|
96
|
+
await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises")
|
|
97
|
+
).readFile;
|
|
98
|
+
let changed = false;
|
|
99
|
+
vi.spyOn(fs, "readFile").mockImplementation(async (...args: Parameters<typeof fs.readFile>) => {
|
|
100
|
+
const bytes = await actualRead(...args);
|
|
101
|
+
if (!changed && String(args[0]) === join(root, source)) {
|
|
102
|
+
changed = true;
|
|
103
|
+
await fs.writeFile(join(root, source), record(3) + "changed length\n");
|
|
104
|
+
}
|
|
105
|
+
return bytes;
|
|
106
|
+
});
|
|
107
|
+
const result = await cache.read(join(root, source), source);
|
|
108
|
+
expect(cache.reads).toBe(2);
|
|
109
|
+
expect(result?.entry.revision[1]).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("treats malformed cache as disposable and prunes origins no longer duplicated", async () => {
|
|
113
|
+
const root = await fixture();
|
|
114
|
+
const peer = await fixture();
|
|
115
|
+
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 10_000);
|
|
116
|
+
const options = { sourceRoots: [root, peer] };
|
|
117
|
+
await sourceFingerprint(root, options);
|
|
118
|
+
const path = join(root, ".local/cache/dashboard-canonical-revisions-v1.json");
|
|
119
|
+
await fs.writeFile(path, "not JSON");
|
|
120
|
+
expect((await sourceFingerprint(root, options)).duplicateReads).toBe(2);
|
|
121
|
+
await fs.rm(join(peer, source));
|
|
122
|
+
await sourceFingerprint(root, options);
|
|
123
|
+
expect(JSON.parse(await fs.readFile(path, "utf8")).entries).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/templates/.agents/dashboard/packages/local-runtime/src/canonical-source-revision-cache.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { BigIntStats } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
type Revision = [string, number, number];
|
|
7
|
+
type Entry = { metadata: string; revision: Revision; contentHash: string; settled: boolean };
|
|
8
|
+
// Covers the observed 12,712 logical sources across ten registered roots.
|
|
9
|
+
const maxEntries = 200_000;
|
|
10
|
+
const maxBytes = 128 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function identity(value: BigIntStats): string {
|
|
13
|
+
return [value.dev, value.ino, value.birthtimeNs, value.mtimeNs, value.ctimeNs, value.size].join(
|
|
14
|
+
":",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function revision(content: string, source: string): Revision {
|
|
19
|
+
if (source.endsWith(".jsonl")) {
|
|
20
|
+
const dates = [...content.matchAll(/"occurred_at"\s*:\s*"([^"]+)"/gu)];
|
|
21
|
+
return [
|
|
22
|
+
dates.reduce((last, match) => ((match[1] ?? "") > last ? match[1]! : last), ""),
|
|
23
|
+
dates.length,
|
|
24
|
+
content.length,
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
const updated = /^updated_at:\s*([^\r\n]+)/mu.exec(content)?.[1]?.trim() ?? "";
|
|
28
|
+
const created = /^created_at:\s*([^\r\n]+)/mu.exec(content)?.[1]?.trim() ?? "";
|
|
29
|
+
return [
|
|
30
|
+
updated || created,
|
|
31
|
+
Number(/^revision:\s*(\d+)/mu.exec(content)?.[1] ?? 0),
|
|
32
|
+
content.length,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validEntry(value: unknown): value is Entry {
|
|
37
|
+
if (!value || typeof value !== "object") return false;
|
|
38
|
+
const item = value as Entry;
|
|
39
|
+
return (
|
|
40
|
+
typeof item.metadata === "string" &&
|
|
41
|
+
/^\d+(?::\d+){5}$/u.test(item.metadata) &&
|
|
42
|
+
typeof item.contentHash === "string" &&
|
|
43
|
+
/^[a-f0-9]{64}$/u.test(item.contentHash) &&
|
|
44
|
+
typeof item.settled === "boolean" &&
|
|
45
|
+
Array.isArray(item.revision) &&
|
|
46
|
+
item.revision.length === 3 &&
|
|
47
|
+
typeof item.revision[0] === "string" &&
|
|
48
|
+
item.revision[0].length <= 256 &&
|
|
49
|
+
Number.isSafeInteger(item.revision[1]) &&
|
|
50
|
+
item.revision[1] >= 0 &&
|
|
51
|
+
Number.isSafeInteger(item.revision[2]) &&
|
|
52
|
+
item.revision[2] >= 0
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Disposable local optimization, never a canonical record or a replacement for inventory. */
|
|
57
|
+
export class CanonicalSourceRevisionCache {
|
|
58
|
+
private readonly entries = new Map<string, Entry>();
|
|
59
|
+
private readonly seen = new Set<string>();
|
|
60
|
+
private dirty = false;
|
|
61
|
+
reads = 0;
|
|
62
|
+
reused = 0;
|
|
63
|
+
private constructor(
|
|
64
|
+
private readonly path: string,
|
|
65
|
+
private readonly verify: boolean,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
static async open(workspaceRoot: string, verify = false): Promise<CanonicalSourceRevisionCache> {
|
|
69
|
+
const cache = new CanonicalSourceRevisionCache(
|
|
70
|
+
resolve(workspaceRoot, ".local/cache/dashboard-canonical-revisions-v1.json"),
|
|
71
|
+
verify,
|
|
72
|
+
);
|
|
73
|
+
if (verify) return cache;
|
|
74
|
+
try {
|
|
75
|
+
if ((await stat(cache.path)).size > maxBytes) return cache;
|
|
76
|
+
const data = JSON.parse(await readFile(cache.path, "utf8")) as {
|
|
77
|
+
version?: unknown;
|
|
78
|
+
entries?: unknown;
|
|
79
|
+
};
|
|
80
|
+
if (data.version !== 1 || !Array.isArray(data.entries) || data.entries.length > maxEntries)
|
|
81
|
+
return cache;
|
|
82
|
+
for (const row of data.entries) {
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(row) ||
|
|
85
|
+
row.length !== 2 ||
|
|
86
|
+
typeof row[0] !== "string" ||
|
|
87
|
+
row[0].length > 4096 ||
|
|
88
|
+
!validEntry(row[1])
|
|
89
|
+
)
|
|
90
|
+
continue;
|
|
91
|
+
cache.entries.set(row[0], row[1]);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* Missing or invalid cache means reread, never failed freshness. */
|
|
95
|
+
}
|
|
96
|
+
return cache;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async read(
|
|
100
|
+
path: string,
|
|
101
|
+
source: string,
|
|
102
|
+
): Promise<{ entry: Entry; metadata: BigIntStats; reused: boolean } | null> {
|
|
103
|
+
const origin = resolve(path);
|
|
104
|
+
if (this.seen.size < maxEntries) this.seen.add(origin);
|
|
105
|
+
try {
|
|
106
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
107
|
+
const before = await stat(origin, { bigint: true });
|
|
108
|
+
const key = identity(before);
|
|
109
|
+
const settled = Date.now() - Number(before.ctimeMs) > 2_000;
|
|
110
|
+
const previous = this.entries.get(origin);
|
|
111
|
+
if (!this.verify && settled && previous?.settled && previous.metadata === key) {
|
|
112
|
+
this.reused += 1;
|
|
113
|
+
return { entry: previous, metadata: before, reused: true };
|
|
114
|
+
}
|
|
115
|
+
const bytes = await readFile(origin);
|
|
116
|
+
this.reads += 1;
|
|
117
|
+
if (identity(await stat(origin, { bigint: true })) !== key) continue;
|
|
118
|
+
const entry: Entry = {
|
|
119
|
+
metadata: key,
|
|
120
|
+
settled,
|
|
121
|
+
revision: revision(bytes.toString("utf8"), source),
|
|
122
|
+
contentHash: createHash("sha256").update(bytes).digest("hex"),
|
|
123
|
+
};
|
|
124
|
+
if (
|
|
125
|
+
!this.verify &&
|
|
126
|
+
this.seen.has(origin) &&
|
|
127
|
+
(this.entries.has(origin) || this.entries.size < maxEntries)
|
|
128
|
+
) {
|
|
129
|
+
this.entries.set(origin, entry);
|
|
130
|
+
this.dirty = true;
|
|
131
|
+
}
|
|
132
|
+
return { entry, metadata: before, reused: false };
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`Canonical source changed repeatedly during revision selection: ${source}`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
137
|
+
this.entries.delete(origin);
|
|
138
|
+
this.dirty = true;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async persist(): Promise<void> {
|
|
146
|
+
if (this.verify) return;
|
|
147
|
+
for (const origin of this.entries.keys()) {
|
|
148
|
+
if (!this.seen.has(origin)) {
|
|
149
|
+
this.entries.delete(origin);
|
|
150
|
+
this.dirty = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!this.dirty) return;
|
|
154
|
+
const retained = [...this.entries].slice(-maxEntries);
|
|
155
|
+
const data = JSON.stringify({ version: 1, entries: retained });
|
|
156
|
+
if (Buffer.byteLength(data) > maxBytes) return;
|
|
157
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
160
|
+
await writeFile(temporary, data, { flag: "wx" });
|
|
161
|
+
await rename(temporary, this.path);
|
|
162
|
+
} catch {
|
|
163
|
+
/* Cache persistence must never prevent canonical verification. */
|
|
164
|
+
} finally {
|
|
165
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { WorkspaceFileSystem } from "@zaip-ai/core";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { reconcileOpenWork } from "./server-workspace.js";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
async function fixture(script: string) {
|
|
9
|
+
const base = resolve(import.meta.dirname, "../../../../../../.tmp/dashboard-cli-diagnostics");
|
|
10
|
+
await mkdir(base, { recursive: true });
|
|
11
|
+
const root = await mkdtemp(join(base, "fixture-"));
|
|
12
|
+
roots.push(root);
|
|
13
|
+
await mkdir(join(root, ".agents/runtime/bin"), { recursive: true });
|
|
14
|
+
await writeFile(join(root, ".agents/runtime/bin/zaip.mjs"), script);
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
async function logs(root: string) {
|
|
18
|
+
const directory = join(root, ".local/dashboard-logs");
|
|
19
|
+
return (
|
|
20
|
+
await Promise.all(
|
|
21
|
+
(await readdir(directory)).map((name) => readFile(join(directory, name), "utf8")),
|
|
22
|
+
)
|
|
23
|
+
).join("");
|
|
24
|
+
}
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
27
|
+
});
|
|
28
|
+
describe("reconciliation CLI diagnostics", () => {
|
|
29
|
+
it("persists real CLI stdout and stderr plus structured outcomes with job correlation", async () => {
|
|
30
|
+
const root = await fixture(
|
|
31
|
+
'process.stderr.write("diagnostic warning\\n"); process.stdout.write(JSON.stringify({ runs: [{run:{run_id:"RUN-X"},status:"failed",finished:{status:"failed"}}] }));',
|
|
32
|
+
);
|
|
33
|
+
const result = await reconcileOpenWork(new WorkspaceFileSystem(root), root, {
|
|
34
|
+
jobId: "JOB-REAL",
|
|
35
|
+
});
|
|
36
|
+
expect(result.outcomes).toEqual([{ id: "RUN-X", status: "failed", reason: null }]);
|
|
37
|
+
const content = await logs(root);
|
|
38
|
+
expect(content).toContain('"job_id":"JOB-REAL"');
|
|
39
|
+
expect(content).toContain("diagnostic warning");
|
|
40
|
+
expect(content).toContain("cli_completed");
|
|
41
|
+
});
|
|
42
|
+
it("retains failed CLI diagnostics and redacts secrets from stderr", async () => {
|
|
43
|
+
const root = await fixture(
|
|
44
|
+
'process.stderr.write("Bearer sensitive password=private\\n"); process.exit(2);',
|
|
45
|
+
);
|
|
46
|
+
await expect(reconcileOpenWork(new WorkspaceFileSystem(root), root)).rejects.toThrow();
|
|
47
|
+
const content = await logs(root);
|
|
48
|
+
expect(content).toContain("cli_error");
|
|
49
|
+
expect(content).not.toMatch(/sensitive|private/);
|
|
50
|
+
expect(content).toContain("[redacted]");
|
|
51
|
+
});
|
|
52
|
+
it("reports invalid CLI JSON as failure with retained output", async () => {
|
|
53
|
+
const root = await fixture('process.stdout.write("invalid-json");');
|
|
54
|
+
await expect(reconcileOpenWork(new WorkspaceFileSystem(root), root)).rejects.toThrow();
|
|
55
|
+
expect(await logs(root)).toContain("cli_invalid_result");
|
|
56
|
+
});
|
|
57
|
+
});
|