@agilno-tech/rivet 0.1.0-alpha.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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/bin/cli.js +42 -0
- package/dist/agents/backend-django-agent/SKILL.md +35 -0
- package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
- package/dist/agents/boss-agent/SKILL.md +28 -0
- package/dist/agents/bounded-worker/SKILL.md +28 -0
- package/dist/agents/data-performance-agent/SKILL.md +32 -0
- package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
- package/dist/agents/devops-agent/SKILL.md +34 -0
- package/dist/agents/engineering-manager/SKILL.md +28 -0
- package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
- package/dist/agents/frontend-web-agent/SKILL.md +31 -0
- package/dist/agents/incident-response-agent/SKILL.md +42 -0
- package/dist/agents/jest-agent/SKILL.md +77 -0
- package/dist/agents/mobile-agent/SKILL.md +32 -0
- package/dist/agents/playwright-agent/SKILL.md +54 -0
- package/dist/agents/product-design-manager/SKILL.md +27 -0
- package/dist/agents/qa-agent/SKILL.md +29 -0
- package/dist/agents/quality-manager/SKILL.md +28 -0
- package/dist/agents/vitest-agent/SKILL.md +77 -0
- package/dist/governance/pre-push-rules.md +75 -0
- package/dist/governance/prompt-hygiene.md +22 -0
- package/dist/governance/review-checklist.md +24 -0
- package/dist/governance/safety-and-data.md +27 -0
- package/dist/governance/usage-rules.md +21 -0
- package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
- package/dist/mandatory/agentic-goal/SKILL.md +42 -0
- package/dist/mandatory/agentic-status/SKILL.md +74 -0
- package/dist/mandatory/apply-design/SKILL.md +228 -0
- package/dist/mandatory/check-ac/SKILL.md +124 -0
- package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
- package/dist/mandatory/design/SKILL.md +312 -0
- package/dist/mandatory/feature-workflow/SKILL.md +87 -0
- package/dist/mandatory/hotfix/SKILL.md +195 -0
- package/dist/mandatory/pre-push/SKILL.md +272 -0
- package/dist/mandatory/project-context/SKILL.md +180 -0
- package/dist/mandatory/release-docs/SKILL.md +191 -0
- package/dist/mandatory/review-pr/SKILL.md +323 -0
- package/dist/mandatory/security-review/SKILL.md +157 -0
- package/dist/skills/api-contract/SKILL.md +77 -0
- package/dist/skills/backend-django/SKILL.md +27 -0
- package/dist/skills/backend-nestjs/SKILL.md +65 -0
- package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
- package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
- package/dist/skills/database-migration/SKILL.md +97 -0
- package/dist/skills/debugging/SKILL.md +32 -0
- package/dist/skills/devops-infra/SKILL.md +26 -0
- package/dist/skills/documentation/SKILL.md +24 -0
- package/dist/skills/frontend-nextjs/SKILL.md +27 -0
- package/dist/skills/incident-postmortem/SKILL.md +38 -0
- package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
- package/dist/skills/mobile-react-native/SKILL.md +37 -0
- package/dist/skills/postgres-analytics/SKILL.md +29 -0
- package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
- package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
- package/dist/skills/refactoring/SKILL.md +32 -0
- package/dist/skills/sprint-planning/SKILL.md +32 -0
- package/dist/skills/testing-quality/SKILL.md +62 -0
- package/dist/v2/protocols/agent-orchestration.md +49 -0
- package/dist/v2/protocols/delivery.md +45 -0
- package/dist/v2/protocols/design-authority.md +29 -0
- package/dist/v2/protocols/goal-graph.md +44 -0
- package/dist/v2/protocols/pattern-first-development.md +29 -0
- package/dist/v2/protocols/qa-evidence.md +29 -0
- package/dist/v2/protocols/security.md +29 -0
- package/dist/v2/schemas/event.schema.json +86 -0
- package/dist/v2/schemas/evidence.schema.json +94 -0
- package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
- package/dist/v2/schemas/feature-plan.schema.json +95 -0
- package/dist/v2/schemas/goal-graph.schema.json +61 -0
- package/dist/v2/schemas/integration.schema.json +119 -0
- package/dist/v2/schemas/orchestration.schema.json +69 -0
- package/dist/v2/schemas/project.schema.json +209 -0
- package/dist/v2/schemas/providers.schema.json +142 -0
- package/dist/v2/schemas/quality.schema.json +50 -0
- package/dist/v2/schemas/work-action.schema.json +44 -0
- package/dist/v2/schemas/work-request.schema.json +253 -0
- package/dist/v2/templates/evidence/qa-bundle.json +194 -0
- package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
- package/dist/v2/templates/harness/SKILL.md +90 -0
- package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
- package/dist/v2/templates/project/.rivet/project.yaml +14 -0
- package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
- package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
- package/package.json +58 -0
- package/protocols/agent-orchestration.md +49 -0
- package/protocols/delivery.md +45 -0
- package/protocols/design-authority.md +29 -0
- package/protocols/goal-graph.md +44 -0
- package/protocols/pattern-first-development.md +29 -0
- package/protocols/qa-evidence.md +29 -0
- package/protocols/security.md +29 -0
- package/schemas/event.schema.json +86 -0
- package/schemas/evidence.schema.json +94 -0
- package/schemas/feature-decomposition.schema.json +48 -0
- package/schemas/feature-plan.schema.json +95 -0
- package/schemas/goal-graph.schema.json +61 -0
- package/schemas/integration.schema.json +119 -0
- package/schemas/orchestration.schema.json +69 -0
- package/schemas/project.schema.json +209 -0
- package/schemas/providers.schema.json +142 -0
- package/schemas/quality.schema.json +50 -0
- package/schemas/work-action.schema.json +44 -0
- package/schemas/work-request.schema.json +253 -0
- package/src/adapters/confluence.js +138 -0
- package/src/adapters/contract.js +657 -0
- package/src/adapters/factory.js +122 -0
- package/src/adapters/figma.js +178 -0
- package/src/adapters/fixtures.js +183 -0
- package/src/adapters/github.js +314 -0
- package/src/adapters/http.js +704 -0
- package/src/adapters/jira.js +195 -0
- package/src/adapters/linear.js +171 -0
- package/src/adapters/node-transport.js +59 -0
- package/src/cli/integration-setup-prompt.js +32 -0
- package/src/cli/interrupt.js +25 -0
- package/src/cli/main.js +596 -0
- package/src/cli/output.js +174 -0
- package/src/cli/parse-args.js +246 -0
- package/src/cli/project-discovery.js +70 -0
- package/src/cli/task-confirmation.js +38 -0
- package/src/cli/task-presentation.js +22 -0
- package/src/clients/claude.js +214 -0
- package/src/clients/codex.js +180 -0
- package/src/clients/compatibility.js +23 -0
- package/src/clients/contract.js +224 -0
- package/src/clients/fake.js +170 -0
- package/src/clients/process-runner.js +657 -0
- package/src/clients/result-contract.js +78 -0
- package/src/commands/delivery-publish.js +80 -0
- package/src/commands/delivery-remote.js +230 -0
- package/src/commands/delivery-review-update.js +39 -0
- package/src/commands/delivery-tracker.js +94 -0
- package/src/commands/delivery-transition.js +79 -0
- package/src/commands/delivery.js +223 -0
- package/src/commands/dependency-approval.js +21 -0
- package/src/commands/doctor.js +191 -0
- package/src/commands/evidence.js +44 -0
- package/src/commands/feature.js +212 -0
- package/src/commands/goals.js +231 -0
- package/src/commands/human-run.js +208 -0
- package/src/commands/human-task.js +278 -0
- package/src/commands/init.js +848 -0
- package/src/commands/install.js +814 -0
- package/src/commands/integration-setup.js +65 -0
- package/src/commands/integrations.js +54 -0
- package/src/commands/models.js +158 -0
- package/src/commands/orchestrate.js +61 -0
- package/src/commands/preflight.js +125 -0
- package/src/commands/protocols.js +228 -0
- package/src/commands/repositories.js +77 -0
- package/src/commands/setup-remote.js +41 -0
- package/src/commands/setup.js +214 -0
- package/src/commands/status.js +224 -0
- package/src/commands/support.js +44 -0
- package/src/commands/task-completion.js +104 -0
- package/src/commands/uninstall.js +234 -0
- package/src/commands/verify.js +171 -0
- package/src/commands/work.js +220 -0
- package/src/commands/worktrees.js +52 -0
- package/src/config/command-readiness.js +218 -0
- package/src/config/commands.js +206 -0
- package/src/config/defaults.js +55 -0
- package/src/config/load.js +230 -0
- package/src/config/validate.js +560 -0
- package/src/delivery/bitbucket-review.js +123 -0
- package/src/delivery/branch-publication.js +23 -0
- package/src/delivery/contract.js +379 -0
- package/src/delivery/github-deployment.js +132 -0
- package/src/delivery/github-review.js +5 -0
- package/src/delivery/github.js +382 -0
- package/src/delivery/gitlab-review.js +5 -0
- package/src/delivery/gitlab.js +395 -0
- package/src/delivery/prepare.js +90 -0
- package/src/delivery/publication-process.js +33 -0
- package/src/delivery/publication-transport.js +112 -0
- package/src/delivery/review-request.js +184 -0
- package/src/delivery/review-update.js +74 -0
- package/src/delivery/service.js +400 -0
- package/src/delivery/store.js +76 -0
- package/src/delivery/tracker-target.js +51 -0
- package/src/delivery/tracker-transition.js +158 -0
- package/src/delivery/tracker.js +164 -0
- package/src/discovery/git.js +85 -0
- package/src/discovery/portable.js +108 -0
- package/src/discovery/project.js +345 -0
- package/src/discovery/tools.js +145 -0
- package/src/evaluations/approval.js +20 -0
- package/src/evaluations/cost-policy.js +20 -0
- package/src/evaluations/harness-attempt.js +113 -0
- package/src/evaluations/live-runner.js +55 -0
- package/src/evaluations/profile.js +28 -0
- package/src/evaluations/report.js +11 -0
- package/src/evaluations/text-attempt.js +18 -0
- package/src/evidence/checksum.js +87 -0
- package/src/evidence/collect.js +727 -0
- package/src/evidence/validate.js +354 -0
- package/src/feature/accepted-integration.js +46 -0
- package/src/feature/actions.js +37 -0
- package/src/feature/client-profile.js +56 -0
- package/src/feature/decomposition-contract.js +58 -0
- package/src/feature/host-execution.js +633 -0
- package/src/feature/host-lock-recovery.js +96 -0
- package/src/feature/host-run-lock.js +32 -0
- package/src/feature/local-approval.js +183 -0
- package/src/feature/plan-contract.js +215 -0
- package/src/feature/planner.js +253 -0
- package/src/feature/run-store.js +241 -0
- package/src/feature/runtime-bridge.js +840 -0
- package/src/feature/verification-report.js +168 -0
- package/src/feature/workflow.js +375 -0
- package/src/git/client.js +509 -0
- package/src/git/integration-worktree.js +153 -0
- package/src/git/reconcile.js +222 -0
- package/src/git/reservations.js +450 -0
- package/src/git/worktrees.js +478 -0
- package/src/graph/completion.js +189 -0
- package/src/graph/fixtures.js +116 -0
- package/src/graph/reducer.js +124 -0
- package/src/graph/scheduler.js +252 -0
- package/src/graph/validate.js +106 -0
- package/src/install/managed.js +579 -0
- package/src/install/project-reference.js +33 -0
- package/src/install/project-runtime.js +142 -0
- package/src/install/runtime-integrity.cjs +141 -0
- package/src/integrations/capabilities.js +69 -0
- package/src/integrations/host-observation.js +26 -0
- package/src/integrations/registry.js +56 -0
- package/src/models/delegate.js +102 -0
- package/src/models/profiles.js +53 -0
- package/src/models/protocols.js +208 -0
- package/src/models/registry.js +83 -0
- package/src/models/transport.js +59 -0
- package/src/policy/approvals.js +251 -0
- package/src/policy/authority.js +282 -0
- package/src/policy/budget.js +149 -0
- package/src/policy/command-bootstrap.js +219 -0
- package/src/policy/commands.js +803 -0
- package/src/prompts/launch-contract.js +43 -0
- package/src/prompts/planning-contract.js +92 -0
- package/src/protocols/presentation.js +53 -0
- package/src/protocols/project.js +289 -0
- package/src/quality/runner.js +497 -0
- package/src/quality/traceability.js +215 -0
- package/src/repositories/identity.js +59 -0
- package/src/repositories/index.js +2 -0
- package/src/repositories/provider.js +210 -0
- package/src/runtime/application.js +353 -0
- package/src/runtime/dependency-directory.js +33 -0
- package/src/runtime/harness-discovery.js +121 -0
- package/src/runtime/heartbeat.js +47 -0
- package/src/runtime/instance-store.js +112 -0
- package/src/runtime/orchestrator.js +1133 -0
- package/src/runtime/portable-dependencies.js +94 -0
- package/src/runtime/recovery.js +190 -0
- package/src/runtime/retry.js +54 -0
- package/src/runtime/supervisor.js +166 -0
- package/src/runtime/worktree-bootstrap.js +169 -0
- package/src/state/event-store.js +333 -0
- package/src/state/lock.js +266 -0
- package/src/state/paths.js +274 -0
- package/src/state/redact.js +106 -0
- package/src/state/snapshot-store.js +310 -0
- package/src/status/public/app.js +96 -0
- package/src/status/public/index.html +37 -0
- package/src/status/public/styles.css +50 -0
- package/src/status/server.js +323 -0
- package/src/status/view-model.js +356 -0
- package/src/support/bundle.js +195 -0
- package/src/support/failure-report.js +96 -0
- package/src/work-request/contract.js +250 -0
- package/src/work-request/host.js +78 -0
- package/src/work-request/local.js +163 -0
- package/src/work-request/tracker.js +105 -0
- package/templates/evidence/qa-bundle.json +194 -0
- package/templates/github-actions/rivet-deploy.yml +98 -0
- package/templates/harness/SKILL.md +90 -0
- package/templates/project/.rivet/orchestration.yaml +42 -0
- package/templates/project/.rivet/project.yaml +14 -0
- package/templates/project/.rivet/providers.yaml +8 -0
- package/templates/project/.rivet/quality.yaml +16 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { assertGitClient, GitClientError } from './client.js';
|
|
5
|
+
import { createReservationStore, ReservationError } from './reservations.js';
|
|
6
|
+
import { assertResolvedStatePaths, verifyResolvedStatePaths } from '../state/paths.js';
|
|
7
|
+
import { evaluateAuthority } from '../policy/authority.js';
|
|
8
|
+
import { verifyApproval } from '../policy/approvals.js';
|
|
9
|
+
|
|
10
|
+
const PROTECTED = /^(?:main|master|trunk|develop|development|production|release(?:\/.*)?)$/i;
|
|
11
|
+
const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
12
|
+
const LEASE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
14
|
+
|
|
15
|
+
export class WorktreeError extends Error {
|
|
16
|
+
constructor(reason = 'invalid-input') {
|
|
17
|
+
const messages = {
|
|
18
|
+
'invalid-input': 'Worktree request is invalid.',
|
|
19
|
+
'dirty-source': 'Source worktree must be clean.',
|
|
20
|
+
'detached-source': 'Source worktree must have an attached branch.',
|
|
21
|
+
'stale-base': 'Requested worktree base is stale or mismatched.',
|
|
22
|
+
'protected-branch': 'Protected branches cannot be Worker branches.',
|
|
23
|
+
'branch-collision': 'Worker branch is already reserved or present.',
|
|
24
|
+
'path-collision': 'Worker worktree path is already reserved or present.',
|
|
25
|
+
'unsafe-path': 'Worker worktree path is unsafe.',
|
|
26
|
+
'repository-mismatch': 'Repository identity does not match private state.',
|
|
27
|
+
'repository-changed': 'Repository identity or topology changed.',
|
|
28
|
+
'create-failed': 'Worker worktree creation failed safely.',
|
|
29
|
+
'rollback-failed': 'Worker worktree rollback requires explicit reconciliation.',
|
|
30
|
+
'lease-mismatch': 'Worktree lease identity does not match.',
|
|
31
|
+
'stale-lease': 'Expired worktree lease requires explicit reconciliation.',
|
|
32
|
+
'recovery-authority': 'Stale worktree recovery authority denied.',
|
|
33
|
+
'recovery-approval': 'Stale worktree recovery requires human approval.',
|
|
34
|
+
'recovery-failed': 'Stale worktree recovery requires explicit intervention.',
|
|
35
|
+
};
|
|
36
|
+
super(messages[reason] ?? messages['invalid-input']);
|
|
37
|
+
this.name = 'WorktreeError';
|
|
38
|
+
this.code = `ERR_WORKTREE_${reason.replaceAll('-', '_').toUpperCase()}`;
|
|
39
|
+
this.safeMessage = this.message;
|
|
40
|
+
this.details = Object.freeze({ reason });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(reason) { throw new WorktreeError(reason); }
|
|
45
|
+
|
|
46
|
+
function capture(value, allowed, required) {
|
|
47
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('invalid-input');
|
|
48
|
+
let keys;
|
|
49
|
+
try { keys = Reflect.ownKeys(value); } catch { fail('invalid-input'); }
|
|
50
|
+
if (keys.some(key => typeof key !== 'string' || !allowed.has(key))) fail('invalid-input');
|
|
51
|
+
const result = Object.create(null);
|
|
52
|
+
try {
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
55
|
+
if (!descriptor?.enumerable) fail('invalid-input');
|
|
56
|
+
result[key] = value[key];
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof WorktreeError) throw error;
|
|
60
|
+
fail('invalid-input');
|
|
61
|
+
}
|
|
62
|
+
if (required.some(key => !Object.hasOwn(result, key))) fail('invalid-input');
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function captureStringArray(value, limit) {
|
|
67
|
+
if (!Array.isArray(value)) fail('invalid-input');
|
|
68
|
+
const result = [];
|
|
69
|
+
try {
|
|
70
|
+
const length = value.length;
|
|
71
|
+
if (!Number.isSafeInteger(length) || length === 0 || length > limit) fail('invalid-input');
|
|
72
|
+
for (let index = 0; index < length; index += 1) {
|
|
73
|
+
if (!Object.hasOwn(value, index)) fail('invalid-input');
|
|
74
|
+
const item = value[index];
|
|
75
|
+
if (typeof item !== 'string') fail('invalid-input');
|
|
76
|
+
result.push(item);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof WorktreeError) throw error;
|
|
80
|
+
fail('invalid-input');
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze(result);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function captureOptions(options) {
|
|
86
|
+
const value = capture(options, new Set(['gitClient', 'nowMs']), ['gitClient']);
|
|
87
|
+
if (value.nowMs !== undefined && (!Number.isSafeInteger(value.nowMs) || value.nowMs < 0)) fail('invalid-input');
|
|
88
|
+
return Object.freeze(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function captureRecoveryOptions(options) {
|
|
92
|
+
const value = capture(options, new Set([
|
|
93
|
+
'gitClient', 'nowMs', 'authority', 'approval', 'approvalRegistry', 'expectedApproverId',
|
|
94
|
+
]), [
|
|
95
|
+
'gitClient', 'nowMs', 'authority', 'approvalRegistry', 'expectedApproverId',
|
|
96
|
+
]);
|
|
97
|
+
if (!Number.isSafeInteger(value.nowMs) || value.nowMs < 0
|
|
98
|
+
|| typeof value.expectedApproverId !== 'string') fail('invalid-input');
|
|
99
|
+
return Object.freeze(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function folded(value) { return value.normalize('NFKC').toLocaleLowerCase('en-US'); }
|
|
103
|
+
|
|
104
|
+
function validAbsolutePath(value) {
|
|
105
|
+
if (typeof value !== 'string' || value.length <= 1 || value.length > 1_024 || !isAbsolute(value)
|
|
106
|
+
|| resolve(value) !== value || /[\u0000\r\n\\:]/.test(value) || value.normalize('NFKC') !== value) return false;
|
|
107
|
+
const parts = value.split(sep).filter(Boolean);
|
|
108
|
+
return parts.every(part => (
|
|
109
|
+
part !== '.' && part !== '..' && !part.endsWith('.') && !part.endsWith(' ')
|
|
110
|
+
&& !/^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(part)
|
|
111
|
+
));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isWithin(parent, candidate) {
|
|
115
|
+
const child = relative(parent, candidate);
|
|
116
|
+
return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function verifyUnusedTarget(path) {
|
|
120
|
+
if (!validAbsolutePath(path)) fail('unsafe-path');
|
|
121
|
+
const parent = dirname(path);
|
|
122
|
+
if (parent === path || parent === sep) fail('unsafe-path');
|
|
123
|
+
let before;
|
|
124
|
+
try { before = await lstat(parent, { bigint: true }); } catch { fail('unsafe-path'); }
|
|
125
|
+
if (!before.isDirectory() || before.isSymbolicLink()) fail('unsafe-path');
|
|
126
|
+
let canonical;
|
|
127
|
+
try { canonical = await realpath(parent); } catch { fail('unsafe-path'); }
|
|
128
|
+
const after = await lstat(parent, { bigint: true });
|
|
129
|
+
if (canonical !== parent || !after.isDirectory() || after.isSymbolicLink()
|
|
130
|
+
|| before.dev !== after.dev || before.ino !== after.ino) fail('unsafe-path');
|
|
131
|
+
try {
|
|
132
|
+
const existing = await lstat(path);
|
|
133
|
+
if (existing.isSymbolicLink() || (!existing.isDirectory() && !existing.isFile())) fail('unsafe-path');
|
|
134
|
+
fail('path-collision');
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof WorktreeError) throw error;
|
|
137
|
+
if (error.code !== 'ENOENT') fail('unsafe-path');
|
|
138
|
+
}
|
|
139
|
+
return Object.freeze({ path, parent, parentIdentity: Object.freeze({ dev: after.dev.toString(), ino: after.ino.toString() }) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function verifyTargetParent(target) {
|
|
143
|
+
const before = await lstat(target.parent, { bigint: true });
|
|
144
|
+
const canonical = await realpath(target.parent);
|
|
145
|
+
const after = await lstat(target.parent, { bigint: true });
|
|
146
|
+
if (canonical !== target.parent || !before.isDirectory() || before.isSymbolicLink()
|
|
147
|
+
|| before.dev !== after.dev || before.ino !== after.ino
|
|
148
|
+
|| after.dev.toString() !== target.parentIdentity.dev || after.ino.toString() !== target.parentIdentity.ino) {
|
|
149
|
+
fail('unsafe-path');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validateCreateInput(input) {
|
|
154
|
+
const value = capture(input, new Set([
|
|
155
|
+
'projectRoot', 'statePaths', 'nodeId', 'branch', 'worktreePath', 'ownerId', 'baseSha',
|
|
156
|
+
'responsibilities', 'intendedPaths', 'expiresAt',
|
|
157
|
+
]), [
|
|
158
|
+
'projectRoot', 'statePaths', 'nodeId', 'branch', 'worktreePath', 'ownerId', 'baseSha',
|
|
159
|
+
'responsibilities', 'intendedPaths', 'expiresAt',
|
|
160
|
+
]);
|
|
161
|
+
if (!validAbsolutePath(value.projectRoot)
|
|
162
|
+
|| !ID.test(value.nodeId) || value.nodeId.length > 64 || !ID.test(value.ownerId) || value.ownerId.length > 64
|
|
163
|
+
|| typeof value.branch !== 'string' || value.branch.length > 200 || value.branch.normalize('NFKC') !== value.branch
|
|
164
|
+
|| !SHA.test(value.baseSha) || typeof value.expiresAt !== 'string') fail('invalid-input');
|
|
165
|
+
if (!validAbsolutePath(value.worktreePath)) fail('unsafe-path');
|
|
166
|
+
assertResolvedStatePaths(value.statePaths);
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
...value,
|
|
169
|
+
responsibilities: captureStringArray(value.responsibilities, 256),
|
|
170
|
+
intendedPaths: captureStringArray(value.intendedPaths, 256),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateVerifyInput(input) {
|
|
175
|
+
const value = capture(input, new Set([
|
|
176
|
+
'projectRoot', 'statePaths', 'nodeId', 'ownerId', 'leaseId',
|
|
177
|
+
]), ['projectRoot', 'statePaths', 'nodeId', 'ownerId', 'leaseId']);
|
|
178
|
+
if (!validAbsolutePath(value.projectRoot) || !ID.test(value.nodeId) || !ID.test(value.ownerId) || !LEASE_ID.test(value.leaseId)) {
|
|
179
|
+
fail('invalid-input');
|
|
180
|
+
}
|
|
181
|
+
assertResolvedStatePaths(value.statePaths);
|
|
182
|
+
return Object.freeze(value);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateRepositoryBinding(repository, input) {
|
|
186
|
+
if (repository.root !== input.projectRoot || repository.gitCommonDir !== input.statePaths.gitCommonDir) fail('repository-mismatch');
|
|
187
|
+
if (repository.detached) fail('detached-source');
|
|
188
|
+
if (repository.dirty) fail('dirty-source');
|
|
189
|
+
if (repository.headSha !== input.baseSha) fail('stale-base');
|
|
190
|
+
if (PROTECTED.test(input.branch)) fail('protected-branch');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function topologyCollision(worktrees, branch, path) {
|
|
194
|
+
const branchKey = folded(branch);
|
|
195
|
+
const pathKey = folded(path);
|
|
196
|
+
if (worktrees.some(item => item.branch && folded(item.branch) === branchKey)) fail('branch-collision');
|
|
197
|
+
if (worktrees.some(item => {
|
|
198
|
+
const existing = folded(item.path);
|
|
199
|
+
return existing === pathKey || existing.startsWith(`${pathKey}${sep}`) || pathKey.startsWith(`${existing}${sep}`);
|
|
200
|
+
})) fail('path-collision');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function identity(reservation) {
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
nodeId: reservation.nodeId,
|
|
206
|
+
ownerId: reservation.ownerId,
|
|
207
|
+
leaseId: reservation.leaseId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function rollbackCreation(client, repository, store, reservation, version) {
|
|
212
|
+
try {
|
|
213
|
+
await client.rollbackWorktreeCreation(repository.root, {
|
|
214
|
+
path: reservation.worktreePath,
|
|
215
|
+
branch: reservation.branch,
|
|
216
|
+
baseSha: reservation.baseSha,
|
|
217
|
+
});
|
|
218
|
+
} catch { return false; }
|
|
219
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
220
|
+
try {
|
|
221
|
+
const current = attempt === 0 ? { version } : await store.list();
|
|
222
|
+
await store.rollback(identity(reservation), { expectedVersion: current.version });
|
|
223
|
+
return true;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!(error instanceof ReservationError) || error.details.reason !== 'state-version-conflict') return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function reserveWithRetry(store, input, nowMs) {
|
|
232
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
233
|
+
const state = await store.list();
|
|
234
|
+
try { return await store.reserve(input, { expectedVersion: state.version, nowMs }); }
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (!(error instanceof ReservationError) || error.details.reason !== 'state-version-conflict') throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw new ReservationError('state-version-conflict');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function activateWithRetry(store, reservation, initialVersion) {
|
|
243
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
244
|
+
const expectedVersion = attempt === 0 ? initialVersion : (await store.list()).version;
|
|
245
|
+
try { return await store.activate(identity(reservation), { expectedVersion }); }
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!(error instanceof ReservationError) || error.details.reason !== 'state-version-conflict') throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
throw new ReservationError('state-version-conflict');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function recoverExpiredWithRetry(store, reservation, nowMs) {
|
|
254
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
255
|
+
const state = await store.list();
|
|
256
|
+
try {
|
|
257
|
+
return await store.recoverExpired(identity(reservation), {
|
|
258
|
+
expectedVersion: state.version,
|
|
259
|
+
nowMs,
|
|
260
|
+
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!(error instanceof ReservationError) || error.details.reason !== 'state-version-conflict') throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw new ReservationError('state-version-conflict');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mapReservationError(error) {
|
|
269
|
+
if (!(error instanceof ReservationError)) throw error;
|
|
270
|
+
if (error.details.reason === 'lease-mismatch') fail('lease-mismatch');
|
|
271
|
+
if (error.details.reason === 'stale-lease') fail('stale-lease');
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function createReservedWorktree(input, options = {}) {
|
|
276
|
+
let value;
|
|
277
|
+
let client;
|
|
278
|
+
try {
|
|
279
|
+
value = validateCreateInput(input);
|
|
280
|
+
options = captureOptions(options);
|
|
281
|
+
client = options.gitClient;
|
|
282
|
+
assertGitClient(client);
|
|
283
|
+
await verifyResolvedStatePaths(value.statePaths);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error instanceof WorktreeError || error instanceof ReservationError) throw error;
|
|
286
|
+
fail('invalid-input');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const target = await verifyUnusedTarget(value.worktreePath);
|
|
290
|
+
let repository;
|
|
291
|
+
try { repository = await client.inspectRepository(value.projectRoot); } catch (error) {
|
|
292
|
+
if (error instanceof GitClientError && error.details.reason === 'executable-config') throw error;
|
|
293
|
+
fail('repository-mismatch');
|
|
294
|
+
}
|
|
295
|
+
validateRepositoryBinding(repository, value);
|
|
296
|
+
const worktrees = await client.listWorktrees(repository.root);
|
|
297
|
+
topologyCollision(worktrees, value.branch, value.worktreePath);
|
|
298
|
+
const existingBranch = await client.branchTip(repository.root, value.branch);
|
|
299
|
+
if (existingBranch !== null) fail('branch-collision');
|
|
300
|
+
|
|
301
|
+
const store = createReservationStore(value.statePaths);
|
|
302
|
+
let reserved;
|
|
303
|
+
try {
|
|
304
|
+
reserved = await reserveWithRetry(store, {
|
|
305
|
+
...value,
|
|
306
|
+
repositoryId: repository.repositoryId,
|
|
307
|
+
}, options.nowMs);
|
|
308
|
+
} catch (error) { mapReservationError(error); }
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await verifyTargetParent(target);
|
|
312
|
+
const before = await client.inspectRepository(repository.root);
|
|
313
|
+
if (before.repositoryId !== repository.repositoryId || before.root !== repository.root
|
|
314
|
+
|| before.headSha !== repository.headSha || before.branch !== repository.branch || before.dirty || before.detached) {
|
|
315
|
+
fail('repository-changed');
|
|
316
|
+
}
|
|
317
|
+
topologyCollision(await client.listWorktrees(repository.root), value.branch, value.worktreePath);
|
|
318
|
+
if (await client.branchTip(repository.root, value.branch) !== null) fail('branch-collision');
|
|
319
|
+
await client.createWorktree(repository.root, {
|
|
320
|
+
path: value.worktreePath,
|
|
321
|
+
branch: value.branch,
|
|
322
|
+
baseSha: value.baseSha,
|
|
323
|
+
});
|
|
324
|
+
const worker = await client.inspectRepository(value.worktreePath);
|
|
325
|
+
if (worker.repositoryId !== repository.repositoryId || worker.root !== value.worktreePath
|
|
326
|
+
|| worker.branch !== value.branch || worker.headSha !== value.baseSha || worker.dirty) {
|
|
327
|
+
fail('repository-changed');
|
|
328
|
+
}
|
|
329
|
+
const topology = await client.listWorktrees(repository.root);
|
|
330
|
+
const matches = topology.filter(item => item.path === value.worktreePath && item.branch === value.branch && item.head === value.baseSha);
|
|
331
|
+
if (matches.length !== 1) fail('repository-changed');
|
|
332
|
+
const active = await activateWithRetry(store, reserved.reservation, reserved.version);
|
|
333
|
+
return Object.freeze({
|
|
334
|
+
reservation: active.reservation,
|
|
335
|
+
repository: Object.freeze({ id: repository.repositoryId, root: repository.root, gitCommonDir: repository.gitCommonDir }),
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const rolledBack = await rollbackCreation(client, repository, store, reserved.reservation, reserved.version);
|
|
339
|
+
if (!rolledBack) fail('rollback-failed');
|
|
340
|
+
if (error instanceof WorktreeError && ['repository-changed', 'branch-collision', 'path-collision', 'unsafe-path'].includes(error.details.reason)) {
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
if (error instanceof ReservationError) throw error;
|
|
344
|
+
if (error instanceof GitClientError) fail('create-failed');
|
|
345
|
+
fail('create-failed');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function verifyReservedWorktree(input, options = {}) {
|
|
350
|
+
let value;
|
|
351
|
+
let client;
|
|
352
|
+
try {
|
|
353
|
+
value = validateVerifyInput(input);
|
|
354
|
+
options = captureOptions(options);
|
|
355
|
+
client = options.gitClient;
|
|
356
|
+
assertGitClient(client);
|
|
357
|
+
await verifyResolvedStatePaths(value.statePaths);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error instanceof WorktreeError || error instanceof ReservationError) throw error;
|
|
360
|
+
fail('invalid-input');
|
|
361
|
+
}
|
|
362
|
+
const store = createReservationStore(value.statePaths);
|
|
363
|
+
let stored;
|
|
364
|
+
try { stored = await store.get({ nodeId: value.nodeId, ownerId: value.ownerId, leaseId: value.leaseId }); }
|
|
365
|
+
catch (error) { mapReservationError(error); }
|
|
366
|
+
const reservation = stored.reservation;
|
|
367
|
+
if (reservation.status !== 'active') fail('lease-mismatch');
|
|
368
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
369
|
+
if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail('invalid-input');
|
|
370
|
+
if (Date.parse(reservation.expiresAt) <= nowMs) fail('stale-lease');
|
|
371
|
+
let repository;
|
|
372
|
+
let worker;
|
|
373
|
+
try {
|
|
374
|
+
repository = await client.inspectRepository(value.projectRoot);
|
|
375
|
+
worker = await client.inspectRepository(reservation.worktreePath);
|
|
376
|
+
} catch { fail('repository-changed'); }
|
|
377
|
+
if (repository.repositoryId !== reservation.repositoryId || repository.gitCommonDir !== value.statePaths.gitCommonDir
|
|
378
|
+
|| worker.repositoryId !== reservation.repositoryId || worker.root !== reservation.worktreePath
|
|
379
|
+
|| worker.branch !== reservation.branch) fail('repository-changed');
|
|
380
|
+
const matches = (await client.listWorktrees(repository.root))
|
|
381
|
+
.filter(item => item.path === reservation.worktreePath && item.branch === reservation.branch);
|
|
382
|
+
if (matches.length !== 1) fail('repository-changed');
|
|
383
|
+
return Object.freeze({
|
|
384
|
+
reservation,
|
|
385
|
+
repository,
|
|
386
|
+
worker,
|
|
387
|
+
stateVersion: stored.version,
|
|
388
|
+
cooperationBoundary: 'cooperating-orchestrator-actors',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function recoverStaleWorktree(input, options = {}) {
|
|
393
|
+
const value = validateVerifyInput(input);
|
|
394
|
+
const recovery = captureRecoveryOptions(options);
|
|
395
|
+
const client = recovery.gitClient;
|
|
396
|
+
assertGitClient(client);
|
|
397
|
+
await verifyResolvedStatePaths(value.statePaths);
|
|
398
|
+
const store = createReservationStore(value.statePaths);
|
|
399
|
+
let stored;
|
|
400
|
+
try { stored = await store.get({ nodeId: value.nodeId, ownerId: value.ownerId, leaseId: value.leaseId }); }
|
|
401
|
+
catch (error) { mapReservationError(error); }
|
|
402
|
+
const reservation = stored.reservation;
|
|
403
|
+
if (Date.parse(reservation.expiresAt) > recovery.nowMs) fail('stale-lease');
|
|
404
|
+
|
|
405
|
+
const authority = evaluateAuthority(recovery.authority, {
|
|
406
|
+
actorId: reservation.ownerId,
|
|
407
|
+
action: 'git.worktree.recover',
|
|
408
|
+
resource: reservation.leaseId,
|
|
409
|
+
});
|
|
410
|
+
if (authority.decision !== 'allow') fail('recovery-authority');
|
|
411
|
+
|
|
412
|
+
const repository = await client.inspectRepository(value.projectRoot);
|
|
413
|
+
if (repository.repositoryId !== reservation.repositoryId
|
|
414
|
+
|| repository.gitCommonDir !== value.statePaths.gitCommonDir) fail('repository-changed');
|
|
415
|
+
const topology = await client.listWorktrees(repository.root);
|
|
416
|
+
const atPath = topology.filter(item => item.path === reservation.worktreePath);
|
|
417
|
+
const onBranch = topology.filter(item => item.branch === reservation.branch);
|
|
418
|
+
let pathMetadata = null;
|
|
419
|
+
try { pathMetadata = await lstat(reservation.worktreePath); }
|
|
420
|
+
catch (error) { if (error.code !== 'ENOENT') fail('repository-changed'); }
|
|
421
|
+
if (atPath.length > 1 || onBranch.length > 1
|
|
422
|
+
|| atPath.some(item => item.branch !== reservation.branch)
|
|
423
|
+
|| onBranch.some(item => item.path !== reservation.worktreePath)
|
|
424
|
+
|| (atPath.length === 0 && pathMetadata !== null)
|
|
425
|
+
|| (atPath.length === 1 && (pathMetadata === null || pathMetadata.isSymbolicLink() || !pathMetadata.isDirectory()))) {
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
status: 'blocked', reason: 'topology-mismatch', reservation,
|
|
428
|
+
evidence: Object.freeze({ changedPaths: Object.freeze([]), uncommittedPaths: Object.freeze([]) }),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const branchTip = await client.branchTip(repository.root, reservation.branch);
|
|
432
|
+
if (atPath.length === 1) {
|
|
433
|
+
const worker = await client.inspectRepository(reservation.worktreePath);
|
|
434
|
+
const changedPaths = await client.changedPaths(worker.root, reservation.baseSha, worker.headSha);
|
|
435
|
+
const evidencePaths = await client.statusPaths(worker.root);
|
|
436
|
+
if (evidencePaths.length > 0 || worker.headSha !== reservation.baseSha || changedPaths.length > 0) {
|
|
437
|
+
return Object.freeze({
|
|
438
|
+
status: 'blocked', reason: 'worker-evidence-preserved', reservation,
|
|
439
|
+
evidence: Object.freeze({ changedPaths, uncommittedPaths: evidencePaths }),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
} else if (branchTip !== null && branchTip !== reservation.baseSha) {
|
|
443
|
+
return Object.freeze({
|
|
444
|
+
status: 'blocked', reason: 'topology-mismatch', reservation,
|
|
445
|
+
evidence: Object.freeze({ changedPaths: Object.freeze([]), uncommittedPaths: Object.freeze([]) }),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const approval = verifyApproval(recovery.approval, {
|
|
450
|
+
subjectId: reservation.ownerId,
|
|
451
|
+
action: 'git.worktree.recover',
|
|
452
|
+
resource: `worktree-lease:${reservation.leaseId}`,
|
|
453
|
+
policyId: 'worktree.stale-recovery',
|
|
454
|
+
}, {
|
|
455
|
+
registry: recovery.approvalRegistry,
|
|
456
|
+
expectedApproverId: recovery.expectedApproverId,
|
|
457
|
+
requireHumanApprover: true,
|
|
458
|
+
requireSingleUse: true,
|
|
459
|
+
nowMs: recovery.nowMs,
|
|
460
|
+
});
|
|
461
|
+
if (!approval.valid) fail('recovery-approval');
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
await client.rollbackWorktreeCreation(repository.root, {
|
|
465
|
+
path: reservation.worktreePath,
|
|
466
|
+
branch: reservation.branch,
|
|
467
|
+
baseSha: reservation.baseSha,
|
|
468
|
+
});
|
|
469
|
+
await recoverExpiredWithRetry(store, reservation, recovery.nowMs);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (error instanceof ReservationError && error.details.reason === 'lease-mismatch') fail('lease-mismatch');
|
|
472
|
+
fail('recovery-failed');
|
|
473
|
+
}
|
|
474
|
+
return Object.freeze({
|
|
475
|
+
status: 'recovered', priorStatus: reservation.status, leaseId: reservation.leaseId,
|
|
476
|
+
cleanup: atPath.length === 1 || branchTip !== null ? 'topology-and-reservation' : 'reservation-only',
|
|
477
|
+
});
|
|
478
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { EVIDENCE_TYPES } from '../config/defaults.js';
|
|
2
|
+
import { graphFailure, sanitizeGraphOperation, validatedGraphSnapshot } from './validate.js';
|
|
3
|
+
|
|
4
|
+
const EVIDENCE_TYPE_SET = new Set(EVIDENCE_TYPES);
|
|
5
|
+
const INTERNAL_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
6
|
+
|
|
7
|
+
function sorted(values) {
|
|
8
|
+
return [...values].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function counts(values) {
|
|
12
|
+
const result = new Map();
|
|
13
|
+
for (const value of values) result.set(value, (result.get(value) ?? 0) + 1);
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function capturedArray(value, maximum, reason, transform = item => item) {
|
|
18
|
+
if (!Array.isArray(value)) graphFailure(reason);
|
|
19
|
+
const length = value.length;
|
|
20
|
+
if (!Number.isSafeInteger(length) || length > maximum) graphFailure(reason);
|
|
21
|
+
const captured = [];
|
|
22
|
+
for (let index = 0; index < length; index += 1) captured.push(transform(value[index], index));
|
|
23
|
+
return captured;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validInternalId(value) {
|
|
27
|
+
return typeof value === 'string' && value.length >= 1 && value.length <= 64 && INTERNAL_ID.test(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function capturedEvidenceItems(items) {
|
|
31
|
+
const ids = new Set();
|
|
32
|
+
return capturedArray(items, 1000, 'invalid-evidence-items', item => {
|
|
33
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) graphFailure('invalid-evidence-item');
|
|
34
|
+
const captured = {
|
|
35
|
+
id: item.id,
|
|
36
|
+
type: item.type,
|
|
37
|
+
approvalState: item.approvalState,
|
|
38
|
+
};
|
|
39
|
+
if (!validInternalId(captured.id) || !EVIDENCE_TYPE_SET.has(captured.type)) {
|
|
40
|
+
graphFailure('invalid-evidence-item');
|
|
41
|
+
}
|
|
42
|
+
if (ids.has(captured.id)) graphFailure('duplicate-evidence-item');
|
|
43
|
+
ids.add(captured.id);
|
|
44
|
+
return captured;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capturedRequiredEvidence(required) {
|
|
49
|
+
const captured = capturedArray(required, EVIDENCE_TYPES.length, 'invalid-required-evidence');
|
|
50
|
+
if (captured.length < 1) {
|
|
51
|
+
graphFailure('invalid-required-evidence');
|
|
52
|
+
}
|
|
53
|
+
if (captured.some(type => !EVIDENCE_TYPE_SET.has(type)) || new Set(captured).size !== captured.length) {
|
|
54
|
+
graphFailure('invalid-required-evidence');
|
|
55
|
+
}
|
|
56
|
+
return captured;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function capturedReferences(references) {
|
|
60
|
+
const captured = capturedArray(references, 64, 'invalid-evidence-references');
|
|
61
|
+
if (captured.length < 1 || captured.some(reference => !validInternalId(reference)) || new Set(captured).size !== captured.length) {
|
|
62
|
+
graphFailure('invalid-evidence-references');
|
|
63
|
+
}
|
|
64
|
+
return captured;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function capturedNode(node) {
|
|
68
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) graphFailure('invalid-node');
|
|
69
|
+
const id = node.id;
|
|
70
|
+
const requiredEvidenceTypes = node.requiredEvidenceTypes;
|
|
71
|
+
const evidenceRefs = node.evidenceRefs;
|
|
72
|
+
const completionProfile = node.completionProfile;
|
|
73
|
+
if (!validInternalId(id) || !validInternalId(completionProfile)) graphFailure('invalid-node');
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
requiredEvidenceTypes: capturedRequiredEvidence(requiredEvidenceTypes),
|
|
77
|
+
evidenceRefs: capturedReferences(evidenceRefs),
|
|
78
|
+
completionProfile,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function capturedProfile(profile) {
|
|
83
|
+
if (profile === undefined) return undefined;
|
|
84
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) graphFailure('invalid-completion-profile');
|
|
85
|
+
const id = profile.id;
|
|
86
|
+
const requiredEvidence = profile.requiredEvidence;
|
|
87
|
+
if (!validInternalId(id)) graphFailure('invalid-completion-profile');
|
|
88
|
+
return { id, requiredEvidence: capturedRequiredEvidence(requiredEvidence) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function evidenceReferenceStatus(node, evidenceItems) {
|
|
92
|
+
const references = new Set(node.evidenceRefs);
|
|
93
|
+
const supplied = new Set(evidenceItems.map(item => item.id));
|
|
94
|
+
return {
|
|
95
|
+
missingRefs: sorted([...references].filter(reference => !supplied.has(reference))),
|
|
96
|
+
unboundRefs: sorted([...supplied].filter(reference => !references.has(reference))),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function completionStatusCaptured(node, evidenceItems, profile) {
|
|
101
|
+
if (profile && profile.id !== node.completionProfile) {
|
|
102
|
+
graphFailure('completion-profile-mismatch');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const required = node.requiredEvidenceTypes;
|
|
106
|
+
const sortedRequired = sorted(required);
|
|
107
|
+
const sortedProfileRequired = profile ? sorted(profile.requiredEvidence) : sortedRequired;
|
|
108
|
+
const profileExact = !profile || (
|
|
109
|
+
sortedProfileRequired.length === sortedRequired.length
|
|
110
|
+
&& sortedProfileRequired.every((type, index) => type === sortedRequired[index])
|
|
111
|
+
);
|
|
112
|
+
const requiredCounts = counts(required);
|
|
113
|
+
const actualCounts = counts(evidenceItems.map(item => item.type));
|
|
114
|
+
const missing = [];
|
|
115
|
+
const unexpected = [];
|
|
116
|
+
|
|
117
|
+
for (const [type, count] of requiredCounts) {
|
|
118
|
+
for (let index = actualCounts.get(type) ?? 0; index < count; index += 1) missing.push(type);
|
|
119
|
+
}
|
|
120
|
+
for (const [type, count] of actualCounts) {
|
|
121
|
+
for (let index = requiredCounts.get(type) ?? 0; index < count; index += 1) unexpected.push(type);
|
|
122
|
+
}
|
|
123
|
+
const unapproved = evidenceItems
|
|
124
|
+
.filter(item => requiredCounts.has(item.type) && item.approvalState !== 'approved')
|
|
125
|
+
.map(item => item.type);
|
|
126
|
+
const { missingRefs, unboundRefs } = evidenceReferenceStatus(node, evidenceItems);
|
|
127
|
+
|
|
128
|
+
const status = {
|
|
129
|
+
complete: (
|
|
130
|
+
profileExact
|
|
131
|
+
&& missing.length === 0
|
|
132
|
+
&& unexpected.length === 0
|
|
133
|
+
&& unapproved.length === 0
|
|
134
|
+
&& missingRefs.length === 0
|
|
135
|
+
&& unboundRefs.length === 0
|
|
136
|
+
),
|
|
137
|
+
missing: sorted(missing),
|
|
138
|
+
unexpected: sorted(unexpected),
|
|
139
|
+
unapproved: sorted(unapproved),
|
|
140
|
+
missingRefs,
|
|
141
|
+
unboundRefs,
|
|
142
|
+
};
|
|
143
|
+
return status;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function completionStatus(node, evidenceItems, profile) {
|
|
147
|
+
return sanitizeGraphOperation(
|
|
148
|
+
() => completionStatusCaptured(
|
|
149
|
+
capturedNode(node),
|
|
150
|
+
capturedEvidenceItems(evidenceItems),
|
|
151
|
+
capturedProfile(profile),
|
|
152
|
+
),
|
|
153
|
+
'invalid-evidence-item',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function graphCompletionStatusUnsafe(graph, evidenceItems, profiles) {
|
|
158
|
+
const stableGraph = validatedGraphSnapshot(graph);
|
|
159
|
+
const stableEvidenceItems = capturedEvidenceItems(evidenceItems);
|
|
160
|
+
const stableProfiles = capturedArray(profiles, 32, 'invalid-completion-profiles', capturedProfile);
|
|
161
|
+
const profileMap = new Map();
|
|
162
|
+
for (const profile of stableProfiles) {
|
|
163
|
+
if (profileMap.has(profile.id)) graphFailure('invalid-completion-profile');
|
|
164
|
+
profileMap.set(profile.id, profile);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const nodes = stableGraph.nodes.map(node => {
|
|
168
|
+
const references = new Set(node.evidenceRefs);
|
|
169
|
+
const selected = stableEvidenceItems.filter(item => references.has(item.id));
|
|
170
|
+
const profile = profileMap.get(node.completionProfile);
|
|
171
|
+
if (stableProfiles.length > 0 && !profile) graphFailure('missing-completion-profile');
|
|
172
|
+
const evidence = completionStatusCaptured(node, selected, profile);
|
|
173
|
+
return { id: node.id, complete: node.status === 'completed' && evidence.complete, evidence };
|
|
174
|
+
});
|
|
175
|
+
const referenced = new Set(stableGraph.nodes.flatMap(node => node.evidenceRefs));
|
|
176
|
+
const unreferenced = sorted(stableEvidenceItems.filter(item => !referenced.has(item.id)).map(item => item.id));
|
|
177
|
+
return {
|
|
178
|
+
complete: stableGraph.status === 'completed' && nodes.every(node => node.complete) && unreferenced.length === 0,
|
|
179
|
+
nodes,
|
|
180
|
+
unreferenced,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function graphCompletionStatus(graph, evidenceItems, profiles = []) {
|
|
185
|
+
return sanitizeGraphOperation(
|
|
186
|
+
() => graphCompletionStatusUnsafe(graph, evidenceItems, profiles),
|
|
187
|
+
'invalid-evidence-item',
|
|
188
|
+
);
|
|
189
|
+
}
|