carthagent 1.0.7 → 1.1.0-preview.13
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 +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +177 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +11 -1
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +8 -4
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +211 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +223 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +125 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +142 -0
- package/plugins/carthagent/src/ui/voyages.tsx +129 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +60 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +11 -0
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +53 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +451 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +395 -0
- package/tests/native-voyages-ui.test.mjs +277 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +546 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +230 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { mkdtempSync } from 'node:fs';
|
|
5
|
+
import { readFile, readdir, rm, stat, writeFile, symlink, mkdir, lstat } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { JsonSchema, Schema, SchemaRepresentation } from 'effect';
|
|
11
|
+
import { CloudClient } from '../lib/cloud/client.mjs';
|
|
12
|
+
import { dailyRequest } from '../plugins/carthagent/src/daily-evidence.mjs';
|
|
13
|
+
const { createJiti } = createRequire(import.meta.url)('jiti');
|
|
14
|
+
const jiti = createJiti(import.meta.url);
|
|
15
|
+
const { createDailyQuestsRpcHandlers } = await jiti.import('../plugins/carthagent/src/server/daily-quests-rpc.ts');
|
|
16
|
+
const { DailyQuests } = await jiti.import('../plugins/carthagent/src/daily-quests-rpc.ts');
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'daily-rpc-'));
|
|
18
|
+
after(() => rm(root, { recursive: true, force: true }));
|
|
19
|
+
const NOW = Date.parse('2026-10-26T23:59:00.000Z'), DAY = 86400000;
|
|
20
|
+
const ID = '12345678-1234-1234-1234-123456789abc';
|
|
21
|
+
const ID2 = '12345678-1234-1234-1234-123456789abd';
|
|
22
|
+
const QID = 'abcdef01-1234-1234-1234-123456789abc';
|
|
23
|
+
const flags = { assessmentBasis: 'tui_submitted', rewards: false };
|
|
24
|
+
const evidence = { projectId: ID, outcomeId: ID, runId: ID, revision: 'a'.repeat(40), goal: 'Selected goal', change: 'Selected change', checks: [], findings: [] };
|
|
25
|
+
const accepted = { id: ID, questId: QID, status: 'queued', decision: null, scores: null, model: null, rubricVersion: null, attempts: 0, createdAt: new Date(NOW).toISOString(), consentAt: new Date(NOW).toISOString(), finishedAt: null, nextAttemptAt: new Date(NOW).toISOString(), errorCode: null, ...flags };
|
|
26
|
+
const graded = { ...accepted, status: 'graded', decision: 'not_met', scores: { outcome: 0.1, 'dynamic-criterion': 0.5 }, model: 'jev-model', rubricVersion: 'daily-rubric-v1:test', attempts: 1, finishedAt: new Date(NOW + 120000).toISOString(), nextAttemptAt: null };
|
|
27
|
+
const quest = { id: QID, day: '2026-10-26', definition: { templateId: 'change-v1', title: 'A real backend objective', kind: 'change', rubricVersion: 'daily-rubric-v1:test', criteria: [{ id: 'outcome', text: 'Concrete outcome?' }] }, status: 'open', endsAt: '2026-10-27T00:00:00.000Z', completedAt: null, ...flags };
|
|
28
|
+
const slate = { day: quest.day, endsAt: quest.endsAt, policyVersion: 'daily-outcomes-v1', items: [quest, ...['tests', 'review'].map((kind, i) => ({ ...quest, id: QID.slice(0, -1) + (i + 1), definition: { ...quest.definition, kind } }))], ...flags };
|
|
29
|
+
const call = { signal: new AbortController().signal, error(type, message, data) { throw Object.assign(new Error(message), { type, data }); } };
|
|
30
|
+
function setup({ disk = false } = {}) {
|
|
31
|
+
const profileRoot = mkdtempSync(join(root, 'profile-')), data = new Map(), requests = [], writes = [];
|
|
32
|
+
let account = 'credential-a', time = NOW;
|
|
33
|
+
const ctx = { integration: { connection: {
|
|
34
|
+
active: async integration => { assert.equal(integration, 'experiential-labs'); return account ? { id: account, type: 'credential', method: 'oauth' } : undefined; },
|
|
35
|
+
resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'never-persist-token' }),
|
|
36
|
+
} } };
|
|
37
|
+
const cloud = { request: async (path, input) => {
|
|
38
|
+
requests.push({ path, ...input, body: input.body ? structuredClone(input.body) : undefined });
|
|
39
|
+
if (path === '/v1/voyages/daily-quests') return structuredClone(slate);
|
|
40
|
+
if (path === `/v1/voyages/daily-quests/${QID}`) return { ...structuredClone(quest), latestSubmission: null };
|
|
41
|
+
if (path.startsWith('/v1/voyages/daily-submissions/')) throw Object.assign(new Error('not found'), { code: 'not_found', status: 404 });
|
|
42
|
+
return { submission: structuredClone(accepted) };
|
|
43
|
+
} };
|
|
44
|
+
const journal = { get: async file => structuredClone(data.get(file)), set: async (file, state) => { writes.push(structuredClone(state)); data.set(file, structuredClone(state)); } };
|
|
45
|
+
const make = (baseUrl = 'https://api.example.test') => createDailyQuestsRpcHandlers(ctx, { cloudClient: cloud, baseUrl, now: () => time, profileRoot, ...(disk ? {} : { journal }) });
|
|
46
|
+
return { ctx, cloud, journal, profileRoot, make, handlers: make(), data, requests, writes, switchAccount: id => { account = id; }, setTime: n => { time = n; } };
|
|
47
|
+
}
|
|
48
|
+
const rejected = (promise, code, uncertain = false) => assert.rejects(promise, error => error.type === 'dailyQuestError' && error.message === code && error.data.code === code && error.data.uncertain === uncertain);
|
|
49
|
+
const key = async s => (await s.handlers.snapshot({}, call)).accountKey;
|
|
50
|
+
const send = async (s, overrides = {}, invocation = call) => s.handlers.submit({ accountKey: await key(s), questId: QID, id: ID, consent: true, evidence, ...overrides }, invocation);
|
|
51
|
+
const raw = s => [...s.data.values()][0]?.records[0];
|
|
52
|
+
const unknown = s => { const original = s.cloud.request; s.cloud.request = async (path, input) => { const result = await original(path, input); if (input.method === 'POST') throw new Error('private network error'); return result; }; };
|
|
53
|
+
|
|
54
|
+
test('fixed six-method contract converts through actual pinned Effect with nullable dynamic scores', () => {
|
|
55
|
+
assert.equal(DailyQuests.id, 'carthagent.dailyQuests');
|
|
56
|
+
assert.deepEqual(Object.keys(DailyQuests.methods), ['snapshot', 'detail', 'submit', 'retry', 'poll', 'forgetPayload']);
|
|
57
|
+
const accountKey = 'a'.repeat(64), record = { id: ID, questId: QID, createdAt: accepted.createdAt, request: dailyRequest(ID, evidence), submission: null };
|
|
58
|
+
const samples = {
|
|
59
|
+
snapshot: [{}, { accountKey, slate, records: [record, { ...record, id: ID2, request: null, submission: { ...graded, id: ID2 } }] }],
|
|
60
|
+
detail: [{ id: QID }, { ...quest, latestSubmission: accepted }],
|
|
61
|
+
submit: [{ accountKey, questId: QID, ...dailyRequest(ID, evidence) }, { submission: accepted }],
|
|
62
|
+
retry: [{ accountKey, id: ID }, { submission: graded }], poll: [{ id: ID }, { submission: accepted }],
|
|
63
|
+
forgetPayload: [{ accountKey, id: ID }, { ok: true }],
|
|
64
|
+
};
|
|
65
|
+
const decode = (schema, value) => Schema.decodeUnknownSync(SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)))(value);
|
|
66
|
+
assert.throws(() => decode({ type: 'string', pattern: '^[a-z]+$' }, 'foo'));
|
|
67
|
+
for (const [name, method] of Object.entries(DailyQuests.methods)) {
|
|
68
|
+
assert.deepEqual(decode(method.input, samples[name][0]), samples[name][0], `${name}:input`);
|
|
69
|
+
assert.deepEqual(decode(method.output, samples[name][1]), samples[name][1], `${name}:output`);
|
|
70
|
+
const error = { code: 'daily_quest_expired', status: 409, uncertain: false };
|
|
71
|
+
assert.deepEqual(decode(method.errors.dailyQuestError, error), error);
|
|
72
|
+
}
|
|
73
|
+
assert.throws(() => decode(DailyQuests.methods.submit.input, { ...samples.submit[0], consent: false }));
|
|
74
|
+
assert.throws(() => decode(DailyQuests.methods.retry.output, { submission: { ...graded, scores: { outcome: 2 } } }));
|
|
75
|
+
assert.deepEqual(decode(DailyQuests.methods.detail.output, { ...quest, latestSubmission: null }), { ...quest, latestSubmission: null });
|
|
76
|
+
});
|
|
77
|
+
test('snapshot, flat detail and explicit poll are GET-only, native bearer/no cookies, safe DTOs', async () => {
|
|
78
|
+
const s = setup(), snapshot = await s.handlers.snapshot({}, call);
|
|
79
|
+
assert.match(snapshot.accountKey, /^[a-f0-9]{64}$/); assert.deepEqual(snapshot.slate, slate); assert.deepEqual(snapshot.records, []);
|
|
80
|
+
assert.deepEqual(await s.handlers.detail({ id: QID }, call), { ...quest, latestSubmission: null });
|
|
81
|
+
const base = s.cloud.request;
|
|
82
|
+
s.cloud.request = async (path, input) => path.includes('daily-submissions/') ? { submission: accepted } : base(path, input);
|
|
83
|
+
assert.deepEqual(await s.handlers.poll({ id: ID }, call), { submission: accepted });
|
|
84
|
+
assert.equal(raw(s).request, null);
|
|
85
|
+
assert.ok(s.requests.every(r => r.method === 'GET' && r.accessToken === 'never-persist-token' && r.signal === call.signal));
|
|
86
|
+
assert.equal(JSON.stringify(snapshot).includes('never-persist-token'), false);
|
|
87
|
+
const wire = [];
|
|
88
|
+
const cloud = new CloudClient({ baseUrl: 'https://api.example.test', fetchImpl: async (url, init) => { wire.push({ url, ...init }); return { ok: true, text: async () => JSON.stringify(slate) }; } });
|
|
89
|
+
await createDailyQuestsRpcHandlers(s.ctx, { profileRoot: s.profileRoot, cloudClient: cloud, journal: s.journal }).snapshot({}, call);
|
|
90
|
+
assert.deepEqual(wire[0].headers, { accept: 'application/json', authorization: 'Bearer never-persist-token' });
|
|
91
|
+
});
|
|
92
|
+
test('consent and evidence are validated server-side before any POST or storage', async () => {
|
|
93
|
+
const s = setup();
|
|
94
|
+
for (const consent of [false, undefined, 'true', 1]) await rejected(send(s, { consent }), 'daily_consent_required');
|
|
95
|
+
await rejected(send(s, { evidence: { ...evidence, goal: 'x'.repeat(1025) } }), 'invalid_daily_evidence');
|
|
96
|
+
await rejected(send(s, { id: 'not-uuid' }), 'invalid_daily_evidence');
|
|
97
|
+
assert.equal(s.requests.filter(r => r.method === 'POST').length, 0); assert.equal(s.writes.length, 0);
|
|
98
|
+
});
|
|
99
|
+
test('durable exact frozen request precedes POST; queued acceptance erases raw immediately, never means completed', async () => {
|
|
100
|
+
const s = setup(), base = s.cloud.request;
|
|
101
|
+
s.cloud.request = async (path, input) => {
|
|
102
|
+
if (input.method === 'POST') assert.deepEqual(raw(s).request, input.body);
|
|
103
|
+
return base(path, input);
|
|
104
|
+
};
|
|
105
|
+
const result = await send(s, { evidence: { ...evidence, goal: ' \r\n selected \r goal ' } });
|
|
106
|
+
assert.equal(result.submission.status, 'queued'); assert.equal(result.submission.decision, null);
|
|
107
|
+
assert.equal(raw(s).request, null); assert.deepEqual(raw(s).submission, accepted);
|
|
108
|
+
assert.equal(s.writes[0].records[0].request.evidence.goal, 'selected \n goal');
|
|
109
|
+
const post = s.requests.find(r => r.method === 'POST');
|
|
110
|
+
assert.equal(post.path, `/v1/voyages/daily-quests/${QID}/submissions`);
|
|
111
|
+
assert.deepEqual(Object.keys(post.body), ['id', 'consent', 'evidence']);
|
|
112
|
+
await rejected(send(s), 'submission_exists');
|
|
113
|
+
});
|
|
114
|
+
test('persist failure and cancellation during write produce zero POSTs, no implicit replay', async () => {
|
|
115
|
+
const s = setup(); s.journal.set = async () => { throw new Error('private disk path'); };
|
|
116
|
+
await rejected(send(s), 'storage_failed'); assert.equal(s.requests.filter(r => r.method === 'POST').length, 0);
|
|
117
|
+
const t = setup(), controller = new AbortController(), save = t.journal.set;
|
|
118
|
+
t.journal.set = async (...args) => { await save(...args); controller.abort(new Error('private cancellation')); };
|
|
119
|
+
await rejected(send(t, {}, { ...call, signal: controller.signal }), 'request_failed', true);
|
|
120
|
+
assert.ok(raw(t).request); assert.equal(t.requests.filter(r => r.method === 'POST').length, 0);
|
|
121
|
+
await t.handlers.snapshot({}, call); assert.equal(t.requests.filter(r => r.method === 'POST').length, 0);
|
|
122
|
+
});
|
|
123
|
+
test('unknown results survive handler restart, block profile-wide new submissions and replay only exact frozen body', async () => {
|
|
124
|
+
const s = setup(), original = s.cloud.request; unknown(s);
|
|
125
|
+
await rejected(send(s), 'request_failed', true);
|
|
126
|
+
const frozen = structuredClone(raw(s).request), accountKey = await key(s);
|
|
127
|
+
const restarted = s.make();
|
|
128
|
+
assert.deepEqual((await restarted.snapshot({}, call)).records[0].request, frozen);
|
|
129
|
+
await rejected(restarted.submit({ accountKey, questId: QID, id: ID2, consent: true, evidence }, call), 'submission_pending', true);
|
|
130
|
+
s.cloud.request = original;
|
|
131
|
+
assert.deepEqual(await restarted.retry({ accountKey, id: ID }, call), { submission: accepted });
|
|
132
|
+
assert.deepEqual(s.requests.filter(r => r.method === 'POST').map(r => r.body), [frozen, frozen]);
|
|
133
|
+
assert.equal(raw(s).request, null);
|
|
134
|
+
await rejected(restarted.retry({ accountKey, id: ID }, call), 'no_pending_payload');
|
|
135
|
+
});
|
|
136
|
+
test('retry reconciles GET first, never blindly replays after auth, conflict or outage, malformed reconciliation retains raw', async () => {
|
|
137
|
+
for (const [code, status] of [['invalid_token', 401], ['insufficient_scope', 403], ['key_conflict', 409], ['private', 503]]) {
|
|
138
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
139
|
+
const accountKey = await key(s), frozen = structuredClone(raw(s).request), count = s.requests.length;
|
|
140
|
+
s.cloud.request = async () => { throw Object.assign(new Error('private'), { code, status }); };
|
|
141
|
+
await rejected(s.handlers.retry({ accountKey, id: ID }, call), code === 'private' ? 'request_rejected' : code, true);
|
|
142
|
+
assert.deepEqual(raw(s).request, frozen); assert.equal(s.requests.length, count);
|
|
143
|
+
}
|
|
144
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
145
|
+
const accountKey = await key(s); s.cloud.request = async () => ({ submission: { ...accepted, id: ID2 } });
|
|
146
|
+
await rejected(s.handlers.retry({ accountKey, id: ID }, call), 'invalid_response', true); assert.ok(raw(s).request);
|
|
147
|
+
s.cloud.request = async () => ({ submission: accepted });
|
|
148
|
+
assert.deepEqual(await s.handlers.retry({ accountKey, id: ID }, call), { submission: accepted }); assert.equal(raw(s).request, null);
|
|
149
|
+
});
|
|
150
|
+
test('fresh definitive backend rejections remove records; unknown errors and all retry POST errors retain them', async () => {
|
|
151
|
+
const definitive = [[400, 'invalid_body'], [401, 'invalid_token'], [403, 'insufficient_scope'], [404, 'not_found'], [409, 'daily_quest_expired'], [429, 'rate_limited']];
|
|
152
|
+
for (const [status, code] of definitive) {
|
|
153
|
+
const s = setup(), original = s.cloud.request;
|
|
154
|
+
s.cloud.request = async (path, input) => { if (input.method === 'POST') throw Object.assign(new Error('private'), { code, status }); return original(path, input); };
|
|
155
|
+
await rejected(send(s), code);
|
|
156
|
+
assert.equal(raw(s), undefined); assert.deepEqual((await s.make().snapshot({}, call)).records, []);
|
|
157
|
+
}
|
|
158
|
+
for (const status of [400, 401, 403, 404, 409, 429, 500, 503, 0]) {
|
|
159
|
+
const s = setup(), original = s.cloud.request;
|
|
160
|
+
s.cloud.request = async (path, input) => { if (input.method === 'POST') throw Object.assign(new Error('private proxy response'), { code: 'private', status }); return original(path, input); };
|
|
161
|
+
await rejected(send(s), status ? 'request_rejected' : 'request_failed', true);
|
|
162
|
+
assert.ok(raw(s).request); assert.equal(raw(s).id, ID); assert.equal(raw(s).submission, null);
|
|
163
|
+
}
|
|
164
|
+
for (const status of [400, 401, 403, 404, 409, 429, 503]) {
|
|
165
|
+
const s = setup(), original = s.cloud.request; unknown(s); await rejected(send(s), 'request_failed', true);
|
|
166
|
+
const accountKey = await key(s);
|
|
167
|
+
s.cloud.request = async (path, input) => { if (input.method === 'POST') throw Object.assign(new Error('private'), { code: 'key_conflict', status }); return original(path, input); };
|
|
168
|
+
await rejected(s.handlers.retry({ accountKey, id: ID }, call), 'key_conflict', true); assert.ok(raw(s).request);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
test('more than 64 definitive fresh rejections never fill the journal or invent acceptance, preserving unknown IDs', async () => {
|
|
172
|
+
const s = setup(), original = s.cloud.request; unknown(s);
|
|
173
|
+
await rejected(send(s), 'request_failed', true);
|
|
174
|
+
const accountKey = await key(s);
|
|
175
|
+
await s.handlers.forgetPayload({ accountKey, id: ID }, call);
|
|
176
|
+
const unresolved = structuredClone(raw(s));
|
|
177
|
+
const definitive = [[400, 'invalid_body'], [401, 'invalid_token'], [403, 'insufficient_scope'], [404, 'not_found'], [409, 'daily_quest_expired'], [429, 'rate_limited']];
|
|
178
|
+
let dispatched = 0;
|
|
179
|
+
for (let i = 0; i < 66; i++) {
|
|
180
|
+
const [status, code] = definitive[i % definitive.length];
|
|
181
|
+
s.cloud.request = async (path, input) => {
|
|
182
|
+
if (input.method === 'POST') { dispatched++; throw Object.assign(new Error('private'), { code, status }); }
|
|
183
|
+
return original(path, input);
|
|
184
|
+
};
|
|
185
|
+
const id = `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`;
|
|
186
|
+
await rejected(s.make().submit({ accountKey, questId: QID, id, consent: true, evidence }, call), code);
|
|
187
|
+
assert.deepEqual((await s.make().snapshot({}, call)).records, [unresolved]);
|
|
188
|
+
}
|
|
189
|
+
assert.equal(dispatched, 66);
|
|
190
|
+
assert.ok(s.writes.every(state => state.records.every(record => record.submission === null)));
|
|
191
|
+
s.cloud.request = original; unknown(s);
|
|
192
|
+
await rejected(send(s, { id: ID2 }), 'request_failed', true);
|
|
193
|
+
const records = (await s.make().snapshot({}, call)).records;
|
|
194
|
+
assert.deepEqual(records[0], unresolved);
|
|
195
|
+
assert.deepEqual(records[1].request, dailyRequest(ID2, evidence));
|
|
196
|
+
assert.equal(records[1].submission, null);
|
|
197
|
+
});
|
|
198
|
+
test('failed definitive-rejection deletion retains the durable disk record and reports uncertainty', async () => {
|
|
199
|
+
const s = setup(), accountKey = await key(s);
|
|
200
|
+
let file, saves = 0;
|
|
201
|
+
const journal = {
|
|
202
|
+
get: async path => {
|
|
203
|
+
try { return JSON.parse(await readFile(path, 'utf8')); }
|
|
204
|
+
catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
205
|
+
},
|
|
206
|
+
set: async (path, state) => {
|
|
207
|
+
file = path;
|
|
208
|
+
if (++saves > 1) throw new Error('private disk failure');
|
|
209
|
+
await writeFile(path, JSON.stringify(state), { mode: 0o600 });
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
s.cloud.request = async () => { throw Object.assign(new Error('private rejection'), { code: 'daily_quest_expired', status: 409 }); };
|
|
213
|
+
const handlers = createDailyQuestsRpcHandlers(s.ctx, { profileRoot: s.profileRoot, baseUrl: 'https://api.example.test', cloudClient: s.cloud, now: () => NOW, journal });
|
|
214
|
+
await rejected(handlers.submit({ accountKey, questId: QID, id: ID, consent: true, evidence }, call), 'storage_failed', true);
|
|
215
|
+
const saved = JSON.parse(await readFile(file, 'utf8'));
|
|
216
|
+
assert.equal(saved.records.length, 1); assert.deepEqual(saved.records[0].request, dailyRequest(ID, evidence));
|
|
217
|
+
assert.equal(saved.records[0].submission, null);
|
|
218
|
+
s.cloud.request = async () => structuredClone(slate);
|
|
219
|
+
assert.deepEqual((await handlers.snapshot({}, call)).records, saved.records);
|
|
220
|
+
});
|
|
221
|
+
test('account-key mutations, identity checks around every request, profile and API isolation', async () => {
|
|
222
|
+
const s = setup(), accountKey = await key(s); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
223
|
+
s.switchAccount('credential-b'); const b = await key(s); assert.notEqual(b, accountKey);
|
|
224
|
+
for (const method of ['submit', 'retry', 'forgetPayload']) await rejected(s.handlers[method]({ accountKey, id: ID, questId: QID, consent: true, evidence }, call), 'account_changed');
|
|
225
|
+
assert.deepEqual((await s.handlers.snapshot({}, call)).records, []);
|
|
226
|
+
s.switchAccount('credential-a');
|
|
227
|
+
assert.equal((await s.make('https://other.example.test').snapshot({}, call)).records.length, 0);
|
|
228
|
+
assert.equal((await s.handlers.snapshot({}, call)).records.length, 1);
|
|
229
|
+
const t = setup(), original = t.cloud.request;
|
|
230
|
+
t.cloud.request = async (path, input) => { const result = await original(path, input); if (input.method === 'POST') t.switchAccount('credential-b'); return result; };
|
|
231
|
+
await rejected(send(t), 'account_changed', true); assert.ok(raw(t).request);
|
|
232
|
+
t.switchAccount(undefined); await rejected(t.handlers.snapshot({}, call), 'authentication_required');
|
|
233
|
+
t.switchAccount('credential-a'); t.ctx.integration.connection.resolve = async () => ({ type: 'oauth', methodID: 'wrong', access: 'private' });
|
|
234
|
+
await rejected(t.handlers.snapshot({}, call), 'authentication_required');
|
|
235
|
+
});
|
|
236
|
+
test('midnight polling retains ID, terminal erases payload, graded alone is not assessed complete', async () => {
|
|
237
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
238
|
+
s.setTime(NOW + 120000); s.cloud.request = async () => ({ submission: graded });
|
|
239
|
+
assert.deepEqual(await s.handlers.poll({ id: ID }, call), { submission: graded });
|
|
240
|
+
assert.equal(raw(s).request, null); assert.equal(raw(s).id, ID); assert.equal(raw(s).questId, QID);
|
|
241
|
+
assert.equal(raw(s).submission.decision, 'not_met'); assert.equal(raw(s).submission.rewards, false);
|
|
242
|
+
});
|
|
243
|
+
test('24h raw expiry and explicit forgetting persist erasure, preserve unresolved ID indefinitely and never auto POST', async () => {
|
|
244
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
245
|
+
s.setTime(NOW + DAY); const snapshot = await s.handlers.snapshot({}, call);
|
|
246
|
+
assert.equal(snapshot.records[0].request, null); assert.equal(raw(s).request, null); assert.equal(raw(s).id, ID);
|
|
247
|
+
s.setTime(NOW + 100 * DAY); assert.equal((await s.handlers.snapshot({}, call)).records[0].id, ID);
|
|
248
|
+
assert.equal(s.requests.filter(r => r.method === 'POST').length, 1);
|
|
249
|
+
const t = setup(); unknown(t); await rejected(send(t), 'request_failed', true);
|
|
250
|
+
assert.deepEqual(await t.handlers.forgetPayload({ accountKey: await key(t), id: ID }, call), { ok: true });
|
|
251
|
+
assert.equal(raw(t).request, null); assert.equal(raw(t).id, ID);
|
|
252
|
+
await rejected(t.handlers.retry({ accountKey: await key(t), id: ID }, call), 'no_pending_payload');
|
|
253
|
+
});
|
|
254
|
+
test('90-day known terminal retention and full unknown journal fail closed rather than evict unresolved IDs', async () => {
|
|
255
|
+
const s = setup(); s.cloud.request = async () => ({ submission: graded });
|
|
256
|
+
await s.handlers.poll({ id: ID }, call);
|
|
257
|
+
s.setTime(NOW + 90 * DAY); s.cloud.request = async () => structuredClone(slate);
|
|
258
|
+
assert.deepEqual((await s.handlers.snapshot({}, call)).records, []);
|
|
259
|
+
const t = setup(); await send(t); const [file] = t.data.keys();
|
|
260
|
+
t.data.set(file, { version: 1, records: Array.from({ length: 64 }, (_, i) => ({ id: `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`, questId: QID, createdAt: accepted.createdAt, request: null, submission: null })) });
|
|
261
|
+
await rejected(send(t), 'journal_full'); assert.equal(t.data.get(file).records.length, 64);
|
|
262
|
+
});
|
|
263
|
+
test('concurrent different IDs serialize across handler instances; only one unknown payload may dispatch', async () => {
|
|
264
|
+
const s = setup(), accountKey = await key(s), original = s.cloud.request;
|
|
265
|
+
s.cloud.request = async (path, input) => {
|
|
266
|
+
const result = await original(path, input);
|
|
267
|
+
if (input.method === 'POST') { await new Promise(resolve => setTimeout(resolve, 30)); throw new Error('unknown'); }
|
|
268
|
+
return result;
|
|
269
|
+
};
|
|
270
|
+
const results = await Promise.allSettled([ID, ID2].map(id => s.make().submit({ accountKey, questId: QID, id, consent: true, evidence }, call)));
|
|
271
|
+
assert.deepEqual(results.map(r => r.reason.data.code).sort(), ['request_failed', 'submission_pending']);
|
|
272
|
+
assert.equal(s.requests.filter(r => r.method === 'POST').length, 1);
|
|
273
|
+
assert.equal([...s.data.values()][0].records.length, 1);
|
|
274
|
+
});
|
|
275
|
+
test('retention erasure failure prevents further HTTP; acceptance erasure failure preserves recovery on disk', async () => {
|
|
276
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
277
|
+
s.setTime(NOW + DAY); const before = s.requests.length;
|
|
278
|
+
s.journal.set = async () => { throw new Error('disk failure'); };
|
|
279
|
+
await rejected(s.handlers.snapshot({}, call), 'storage_failed');
|
|
280
|
+
assert.equal(s.requests.length, before); assert.ok(raw(s).request);
|
|
281
|
+
const t = setup(), original = t.cloud.request, save = t.journal.set;
|
|
282
|
+
t.cloud.request = async (path, input) => { const result = await original(path, input); if (input.method === 'POST') t.journal.set = async () => { throw new Error('disk failure'); }; return result; };
|
|
283
|
+
await rejected(send(t), 'storage_failed', true); assert.ok(raw(t).request);
|
|
284
|
+
t.journal.set = save; t.cloud.request = async () => ({ submission: accepted });
|
|
285
|
+
assert.deepEqual(await t.make().poll({ id: ID }, call), { submission: accepted }); assert.equal(raw(t).request, null);
|
|
286
|
+
});
|
|
287
|
+
test('full journal evicts only oldest known terminal; active and unresolved records remain', async () => {
|
|
288
|
+
const s = setup(); await send(s); const [file] = s.data.keys();
|
|
289
|
+
const records = Array.from({ length: 64 }, (_, i) => {
|
|
290
|
+
const id = `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`;
|
|
291
|
+
return { id, questId: QID, createdAt: new Date(NOW + i).toISOString(), request: null, submission: i < 2 ? { ...graded, id } : i === 2 ? { ...accepted, id } : null };
|
|
292
|
+
});
|
|
293
|
+
s.data.set(file, { version: 1, records });
|
|
294
|
+
await send(s);
|
|
295
|
+
const saved = s.data.get(file).records;
|
|
296
|
+
assert.equal(saved.length, 64); assert.equal(saved.some(r => r.id === records[0].id), false);
|
|
297
|
+
assert.ok(saved.some(r => r.id === records[1].id)); assert.ok(saved.some(r => r.id === records[2].id));
|
|
298
|
+
assert.ok(saved.some(r => r.id === records[63].id));
|
|
299
|
+
});
|
|
300
|
+
test('bounded validators reject malformed DTOs and identity mismatches without dropping unknown raw', async () => {
|
|
301
|
+
for (const mutate of [s => { s.id = ID2; }, s => { s.questId = ID2; }, s => { s.scores = { criterion: 2 }; }, s => { s.attempts = 5; }, s => { s.rewards = true; }, s => { s.status = 'expired'; }, s => { s.decision = 'assessed_complete'; }, s => { s.finishedAt = '2026-02-30T00:00:00Z'; }]) {
|
|
302
|
+
const s = setup(), original = s.cloud.request, dto = structuredClone(accepted); mutate(dto);
|
|
303
|
+
s.cloud.request = async (path, input) => input.method === 'POST' ? { submission: dto } : original(path, input);
|
|
304
|
+
await rejected(send(s), 'invalid_response', true); assert.ok(raw(s).request);
|
|
305
|
+
}
|
|
306
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
307
|
+
s.cloud.request = async () => ({ submission: { ...graded, questId: ID2 } });
|
|
308
|
+
await rejected(s.handlers.poll({ id: ID }, call), 'invalid_response'); assert.ok(raw(s).request);
|
|
309
|
+
s.cloud.request = async () => ({ ...quest, id: ID2, latestSubmission: null });
|
|
310
|
+
await rejected(s.handlers.detail({ id: QID }, call), 'invalid_response');
|
|
311
|
+
});
|
|
312
|
+
test('queued/grading errorCode remains nonterminal metadata; safe backend errors never echo raw detail', async () => {
|
|
313
|
+
for (const status of ['queued', 'grading']) {
|
|
314
|
+
const s = setup(), dto = { ...accepted, status, errorCode: 'daily_grader_timeout', nextAttemptAt: status === 'grading' ? null : accepted.nextAttemptAt };
|
|
315
|
+
s.cloud.request = async () => ({ submission: dto });
|
|
316
|
+
assert.equal((await s.handlers.poll({ id: ID }, call)).submission.status, status);
|
|
317
|
+
assert.equal(raw(s).request, null);
|
|
318
|
+
}
|
|
319
|
+
const s = setup(); s.cloud.request = async () => { throw Object.assign(new Error('private-token'), { code: 'private-token', detail: 'private-token', status: 503 }); };
|
|
320
|
+
await rejected(s.handlers.snapshot({}, call), 'request_rejected');
|
|
321
|
+
});
|
|
322
|
+
test('private disk journal, corrupt and oversized files, symlinks fail closed and avoid all POSTs', async () => {
|
|
323
|
+
const s = setup({ disk: true }); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
324
|
+
const directory = join(s.profileRoot, 'state', 'carthagent', 'daily-quests'), [name] = (await readdir(directory)).filter(n => n.endsWith('.json')), file = join(directory, name);
|
|
325
|
+
if (process.platform !== 'win32') { assert.equal((await stat(file)).mode & 0o777, 0o600); assert.equal((await stat(directory)).mode & 0o777, 0o700); }
|
|
326
|
+
const saved = await readFile(file, 'utf8'); assert.equal(saved.includes('never-persist-token'), false);
|
|
327
|
+
for (const bad of ['{broken', 'x'.repeat(262145), JSON.stringify({ version: 1, records: [{}] })]) {
|
|
328
|
+
await writeFile(file, bad); const before = s.requests.length;
|
|
329
|
+
await rejected(s.make().submit({ accountKey: name.slice(0, -5), questId: QID, id: ID2, consent: true, evidence }, call), 'journal_invalid');
|
|
330
|
+
assert.equal(s.requests.length, before);
|
|
331
|
+
}
|
|
332
|
+
await rm(file); const target = join(s.profileRoot, 'target.json'); await writeFile(target, saved); await symlink(target, file);
|
|
333
|
+
const before = s.requests.length;
|
|
334
|
+
for (const invoke of [
|
|
335
|
+
() => s.handlers.snapshot({}, call),
|
|
336
|
+
() => s.handlers.submit({ accountKey: name.slice(0, -5), questId: QID, id: ID2, consent: true, evidence }, call),
|
|
337
|
+
]) await rejected(invoke(), 'journal_invalid');
|
|
338
|
+
assert.equal(s.requests.length, before); assert.equal(await readFile(target, 'utf8'), saved);
|
|
339
|
+
await rm(target); // A dangling link must not be mistaken for an absent journal.
|
|
340
|
+
await rejected(s.handlers.snapshot({}, call), 'journal_invalid');
|
|
341
|
+
assert.equal(s.requests.length, before);
|
|
342
|
+
});
|
|
343
|
+
test('locked journal access erases only exact account staging files, with main present or absent, without following symlinks', async () => {
|
|
344
|
+
for (const mode of ['absent', 'expired', 'forget']) {
|
|
345
|
+
const s = setup({ disk: true }), accountKey = await key(s);
|
|
346
|
+
const directory = join(s.profileRoot, 'state', 'carthagent', 'daily-quests');
|
|
347
|
+
const file = join(directory, `${accountKey}.json`);
|
|
348
|
+
const record = { id: ID, questId: QID, createdAt: accepted.createdAt, request: dailyRequest(ID, { ...evidence, goal: 'private orphan evidence' }), submission: null };
|
|
349
|
+
const state = JSON.stringify({ version: 1, records: [record] });
|
|
350
|
+
if (mode !== 'absent') await writeFile(file, state, { mode: 0o600 });
|
|
351
|
+
// Crash after staging bytes/fsync, before rename, including when the main
|
|
352
|
+
// file was never created. A live-looking PID is safe under the scope lock.
|
|
353
|
+
const staging = `${file}.${process.pid}.${ID}.tmp`;
|
|
354
|
+
await writeFile(staging, state, { mode: 0o600 });
|
|
355
|
+
const target = join(s.profileRoot, 'untouched-target.txt');
|
|
356
|
+
await writeFile(target, 'target must remain');
|
|
357
|
+
const link = `${file}.123.${ID2}.tmp`;
|
|
358
|
+
await symlink(target, link);
|
|
359
|
+
const ignored = [
|
|
360
|
+
`${file}.0.${ID}.tmp`, `${file}.01.${ID}.tmp`, `${file}.-1.${ID}.tmp`,
|
|
361
|
+
`${file}.123.not-uuid.tmp`, `${file}.123.${ID}.tmp.backup`,
|
|
362
|
+
join(directory, `${'f'.repeat(64)}.json.123.${ID}.tmp`),
|
|
363
|
+
join(directory, `unrelated.json.123.${ID}.tmp`),
|
|
364
|
+
];
|
|
365
|
+
for (const path of ignored) await writeFile(path, 'unrelated contents');
|
|
366
|
+
const nonfile = `${file}.456.${ID2}.tmp`;
|
|
367
|
+
await mkdir(nonfile); await writeFile(join(nonfile, 'child'), 'directory contents');
|
|
368
|
+
if (mode === 'expired') s.setTime(NOW + DAY);
|
|
369
|
+
const original = s.cloud.request;
|
|
370
|
+
s.cloud.request = async (path, input) => {
|
|
371
|
+
await assert.rejects(lstat(staging), { code: 'ENOENT' });
|
|
372
|
+
await assert.rejects(lstat(link), { code: 'ENOENT' });
|
|
373
|
+
return original(path, input);
|
|
374
|
+
};
|
|
375
|
+
if (mode === 'forget') assert.deepEqual(await s.make().forgetPayload({ accountKey, id: ID }, call), { ok: true });
|
|
376
|
+
else {
|
|
377
|
+
const snapshot = await s.make().snapshot({}, call);
|
|
378
|
+
assert.deepEqual(snapshot.records, mode === 'absent' ? [] : [{ ...record, request: null }]);
|
|
379
|
+
assert.equal(JSON.stringify(snapshot).includes('private orphan evidence'), false);
|
|
380
|
+
}
|
|
381
|
+
await assert.rejects(lstat(staging), { code: 'ENOENT' });
|
|
382
|
+
await assert.rejects(lstat(link), { code: 'ENOENT' });
|
|
383
|
+
assert.equal(await readFile(target, 'utf8'), 'target must remain');
|
|
384
|
+
for (const path of ignored) assert.equal(await readFile(path, 'utf8'), 'unrelated contents');
|
|
385
|
+
assert.equal(await readFile(join(nonfile, 'child'), 'utf8'), 'directory contents');
|
|
386
|
+
if (mode === 'absent') await assert.rejects(lstat(file), { code: 'ENOENT' });
|
|
387
|
+
else assert.deepEqual(JSON.parse(await readFile(file, 'utf8')).records, [{ ...record, request: null }]);
|
|
388
|
+
assert.equal(s.requests.some(r => r.method === 'POST'), false);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
test('staging cleanup failure is under the account lock, precedes main read and blocks HTTP without changing records', async () => {
|
|
392
|
+
const s = setup(); unknown(s); await rejected(send(s), 'request_failed', true);
|
|
393
|
+
const accountKey = await key(s), before = structuredClone([...s.data.entries()]);
|
|
394
|
+
let reads = 0, cleanups = 0;
|
|
395
|
+
s.journal.cleanup = async file => {
|
|
396
|
+
assert.equal(file.endsWith(`${accountKey}.json`), true);
|
|
397
|
+
assert.equal((await stat(file.slice(0, -5) + '.lock')).isFile(), true);
|
|
398
|
+
cleanups++;
|
|
399
|
+
throw new Error('private cleanup path and contents');
|
|
400
|
+
};
|
|
401
|
+
s.journal.get = async () => { reads++; assert.fail('cleanup failure must precede journal read'); };
|
|
402
|
+
const requests = s.requests.length;
|
|
403
|
+
await rejected(s.handlers.snapshot({}, call), 'storage_failed');
|
|
404
|
+
await rejected(s.handlers.forgetPayload({ accountKey, id: ID }, call), 'storage_failed');
|
|
405
|
+
await rejected(s.handlers.submit({ accountKey, questId: QID, id: ID2, consent: true, evidence }, call), 'storage_failed');
|
|
406
|
+
assert.equal(cleanups, 3); assert.equal(reads, 0); assert.equal(s.requests.length, requests);
|
|
407
|
+
assert.deepEqual([...s.data.entries()], before);
|
|
408
|
+
});
|
|
409
|
+
test('cross-process serialization and crash/restart replay preserve the exact canonical UUID/body', async () => {
|
|
410
|
+
const profileRoot = mkdtempSync(join(root, 'processes-'));
|
|
411
|
+
const module = fileURLToPath(new URL('../plugins/carthagent/src/server/daily-quests-rpc.ts', import.meta.url));
|
|
412
|
+
const source = `
|
|
413
|
+
import { createRequire } from 'node:module';
|
|
414
|
+
import { appendFile } from 'node:fs/promises';
|
|
415
|
+
const { createJiti } = createRequire(import.meta.url)('jiti');
|
|
416
|
+
const { createDailyQuestsRpcHandlers } = await createJiti(import.meta.url).import(${JSON.stringify(module)});
|
|
417
|
+
const ctx = { integration: { connection: {
|
|
418
|
+
active: async () => ({ id: 'shared-account', type: 'credential', method: 'oauth' }),
|
|
419
|
+
resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'not-persisted' }),
|
|
420
|
+
} } };
|
|
421
|
+
const cloud = { request: async (path, input) => {
|
|
422
|
+
if (path === '/v1/voyages/daily-quests') return ${JSON.stringify(slate)};
|
|
423
|
+
if (input.method === 'GET') throw Object.assign(new Error('absent'), { code: 'not_found', status: 404 });
|
|
424
|
+
await appendFile(${JSON.stringify(join(profileRoot, 'sent.jsonl'))}, JSON.stringify(input.body) + '\\n');
|
|
425
|
+
if (process.argv[1] === 'crash') process.exit(17);
|
|
426
|
+
if (process.argv[1].startsWith('retry')) return { submission: { ...${JSON.stringify(accepted)}, id: input.body.id } };
|
|
427
|
+
await new Promise(r => setTimeout(r, 100)); throw new Error('unknown');
|
|
428
|
+
} };
|
|
429
|
+
const h = createDailyQuestsRpcHandlers(ctx, { cloudClient: cloud, profileRoot: ${JSON.stringify(profileRoot)}, now: () => ${NOW} });
|
|
430
|
+
const call = { signal: new AbortController().signal, error(type, message, data) { throw Object.assign(new Error(message), { data }); } };
|
|
431
|
+
try {
|
|
432
|
+
const { accountKey } = await h.snapshot({}, call);
|
|
433
|
+
const result = process.argv[1].startsWith('retry') ? await h.retry({ accountKey, id: process.argv[1] === 'retry-crash' ? ${JSON.stringify(ID2)} : ${JSON.stringify(ID)} }, call)
|
|
434
|
+
: await h.submit({ accountKey, questId: ${JSON.stringify(QID)}, id: process.argv[1] === 'crash' ? ${JSON.stringify(ID2)} : ${JSON.stringify(ID)}, consent: true, evidence: ${JSON.stringify(evidence)} }, call);
|
|
435
|
+
console.log(JSON.stringify(result));
|
|
436
|
+
} catch(e) { console.log(JSON.stringify(e.data)); }
|
|
437
|
+
`;
|
|
438
|
+
const child = mode => new Promise((resolve, reject) => {
|
|
439
|
+
const p = spawn(process.execPath, ['--input-type=module', '-e', source, mode], { cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
440
|
+
let out = '', err = ''; p.stdout.on('data', x => { out += x; }); p.stderr.on('data', x => { err += x; });
|
|
441
|
+
p.on('error', reject); p.on('close', code => { if (code) return reject(new Error(`child_exit_${code}: ${err}`)); try { resolve(JSON.parse(out.trim())); } catch (error) { reject(error); } });
|
|
442
|
+
});
|
|
443
|
+
const results = await Promise.all([child('submit'), child('submit')]);
|
|
444
|
+
assert.deepEqual(results.map(r => r.code).sort(), ['request_failed', 'submission_exists']);
|
|
445
|
+
assert.deepEqual(await child('retry'), { submission: accepted });
|
|
446
|
+
const sent = (await readFile(join(profileRoot, 'sent.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse);
|
|
447
|
+
assert.equal(sent.length, 2); assert.deepEqual(sent[0], sent[1]);
|
|
448
|
+
await assert.rejects(child('crash'), /child_exit_17/);
|
|
449
|
+
const directory = join(profileRoot, 'state', 'carthagent', 'daily-quests');
|
|
450
|
+
const [name] = (await readdir(directory)).filter(n => n.endsWith('.json'));
|
|
451
|
+
const state = JSON.parse(await readFile(join(directory, name), 'utf8'));
|
|
452
|
+
assert.equal(state.records.find(r => r.id === ID2).request.id, ID2);
|
|
453
|
+
assert.deepEqual(await child('retry-crash'), { submission: { ...accepted, id: ID2 } });
|
|
454
|
+
const afterCrash = (await readFile(join(profileRoot, 'sent.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse);
|
|
455
|
+
assert.equal(afterCrash.length, 4); assert.deepEqual(afterCrash[2], afterCrash[3]);
|
|
456
|
+
});
|