@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,158 @@
|
|
|
1
|
+
import { captureRecord, createProviderReadBody, createProviderWireBody } from '../adapters/contract.js';
|
|
2
|
+
import { createProviderHttpClient } from '../adapters/http.js';
|
|
3
|
+
import { parseRepositoryRemote } from '../repositories/identity.js';
|
|
4
|
+
import { createTrustedDeliveryExecutor } from './service.js';
|
|
5
|
+
import { digest, ensure, exact, factsDigest, hash, id, plain, sha, timestamp, validateCandidate, validateReceipt } from './contract.js';
|
|
6
|
+
|
|
7
|
+
const KEY = /^[A-Z][A-Z0-9]{0,31}-[1-9][0-9]{0,15}$/;
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
|
+
const NUMBER = /^[1-9][0-9]{0,19}$/;
|
|
10
|
+
const ISSUE_QUERY = 'query RivetTrackerStatus($id: String!) { issue(id: $id) { id identifier url updatedAt team { id key } state { id name type team { id key } } } }';
|
|
11
|
+
const STATES_QUERY = 'query RivetTrackerStates($filter: WorkflowStateFilter, $cursor: String) { workflowStates(first: 100, after: $cursor, filter: $filter) { nodes { id name type team { id key } } pageInfo { hasNextPage endCursor } } }';
|
|
12
|
+
function label(value) { ensure(typeof value === 'string' && value.length >= 1 && value.length <= 200 && value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value), 'invalid-provider-evidence'); return value; }
|
|
13
|
+
function revision(value) { ensure(typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value)), 'invalid-provider-evidence'); return value; }
|
|
14
|
+
|
|
15
|
+
/** Discover destinations and bind a human choice. Status reads never create journal entries. */
|
|
16
|
+
export async function createTrackerTransition(inputConfig) {
|
|
17
|
+
const input = captureRecord(inputConfig, new Set(['repository', 'target', 'mergeReceipt', 'providerId', 'baseUrl', 'headers', 'transport', 'clock', 'timeoutMs', 'persistedPayload']), ['repository', 'target', 'mergeReceipt', 'providerId', 'baseUrl', 'transport'], 'invalid-config');
|
|
18
|
+
const supplied = plain(input.repository), repository = parseRepositoryRemote(supplied.url);
|
|
19
|
+
ensure(hash(repository) === hash(supplied));
|
|
20
|
+
const target = plain(input.target); exact(target, ['kind', 'issueKey', 'issueUrl', 'requestDigest']);
|
|
21
|
+
ensure(['jira', 'linear'].includes(target.kind) && typeof target.issueKey === 'string' && KEY.test(target.issueKey));
|
|
22
|
+
digest(target.requestDigest); id(input.providerId);
|
|
23
|
+
const jira = target.kind === 'jira';
|
|
24
|
+
let url; try { url = new URL(target.issueUrl); } catch { ensure(false); }
|
|
25
|
+
ensure(url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash && url.href === target.issueUrl);
|
|
26
|
+
ensure(jira ? /^https:\/\/[a-z0-9][a-z0-9-]*\.atlassian\.net$/.test(input.baseUrl)
|
|
27
|
+
&& target.issueUrl === `${input.baseUrl}/browse/${target.issueKey}`
|
|
28
|
+
: input.baseUrl === 'https://api.linear.app' && url.origin === 'https://linear.app'
|
|
29
|
+
&& new RegExp(`^/[^/]+/issue/${target.issueKey}(?:/[^/]+)?/?$`).test(url.pathname));
|
|
30
|
+
const merged = plain(input.mergeReceipt);
|
|
31
|
+
validateReceipt(merged, { action: 'merge', digest: digest(merged.operationDigest), candidate: { headSha: sha(merged.headSha) } });
|
|
32
|
+
const prefix = `${repository.url}/${repository.provider === 'github' ? 'pull' : repository.provider === 'gitlab' ? '-/merge_requests' : 'pull-requests'}/`;
|
|
33
|
+
const reviewNumber = Number(merged.resourceUrl.slice(prefix.length));
|
|
34
|
+
ensure(Number.isSafeInteger(reviewNumber) && reviewNumber > 0 && merged.resourceUrl === prefix + reviewNumber);
|
|
35
|
+
const clock = input.clock ?? (() => new Date().toISOString()); ensure(typeof clock === 'function');
|
|
36
|
+
const http = createProviderHttpClient({ provider: target.kind, baseUrl: input.baseUrl, transport: input.transport,
|
|
37
|
+
...(input.headers === undefined ? {} : { headers: input.headers }), ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }) });
|
|
38
|
+
const internal = value => { ensure(typeof value === 'string' && (jira ? NUMBER : UUID).test(value), 'invalid-provider-evidence'); return value; };
|
|
39
|
+
function owner(value) { internal(value?.id); ensure(value.key === target.issueKey.split('-')[0], 'changed-facts'); return plain({ id: value.id, key: value.key }); }
|
|
40
|
+
function state(value, expectedOwner) {
|
|
41
|
+
internal(value?.id);
|
|
42
|
+
if (!jira) ensure(hash(owner(value.team)) === hash(expectedOwner), 'changed-facts');
|
|
43
|
+
return plain({ id: value.id, name: label(value.name), type: label(jira ? value.statusCategory?.key : value.type) });
|
|
44
|
+
}
|
|
45
|
+
async function graphql(query, variables) {
|
|
46
|
+
const result = (await http.request({ method: 'POST', path: '/graphql', wireBody: createProviderReadBody({ provider: 'linear', payload: { query, variables } }) })).data;
|
|
47
|
+
ensure(result && !Object.hasOwn(result, 'errors') && result.data && typeof result.data === 'object', 'invalid-provider-evidence');
|
|
48
|
+
return result.data;
|
|
49
|
+
}
|
|
50
|
+
async function issue(selected, expected) {
|
|
51
|
+
const raw = jira ? (await http.request({ method: 'GET', path: `/rest/api/3/issue/${encodeURIComponent(selected)}?fields=updated,project,status` })).data
|
|
52
|
+
: (await graphql(ISSUE_QUERY, { id: selected })).issue;
|
|
53
|
+
internal(raw?.id);
|
|
54
|
+
ensure((jira ? raw.key : raw.identifier) === target.issueKey && (jira || raw.url === target.issueUrl), 'changed-facts');
|
|
55
|
+
const identity = owner(jira ? raw.fields?.project : raw.team);
|
|
56
|
+
if (expected) ensure(raw.id === expected.id && hash(identity) === hash(expected.owner), 'changed-facts');
|
|
57
|
+
return plain({ id: raw.id, key: target.issueKey, owner: identity,
|
|
58
|
+
revision: revision(jira ? raw.fields.updated : raw.updatedAt), state: state(jira ? raw.fields.status : raw.state, identity) });
|
|
59
|
+
}
|
|
60
|
+
async function destinations(current) {
|
|
61
|
+
if (jira) {
|
|
62
|
+
const raw = (await http.request({ method: 'GET', path: `/rest/api/3/issue/${current.id}/transitions?expand=transitions.fields` })).data;
|
|
63
|
+
ensure(Array.isArray(raw?.transitions) && raw.transitions.length <= 100, 'invalid-provider-evidence');
|
|
64
|
+
const result = raw.transitions.map(value => {
|
|
65
|
+
internal(value.id); label(value.name);
|
|
66
|
+
ensure(typeof value.hasScreen === 'boolean' && value.fields && typeof value.fields === 'object' && !Array.isArray(value.fields), 'invalid-provider-evidence');
|
|
67
|
+
const fields = Object.values(value.fields);
|
|
68
|
+
ensure(fields.length <= 100 && fields.every(field => field && typeof field.required === 'boolean'), 'invalid-provider-evidence');
|
|
69
|
+
const eligible = !value.hasScreen && fields.every(field => !field.required);
|
|
70
|
+
return plain({ id: value.id, name: value.name, state: state(value.to, current.owner), eligible,
|
|
71
|
+
reason: eligible ? null : 'requires-fields-or-screen', metadataDigest: hash({ to: value.to, fields: value.fields, hasScreen: value.hasScreen }) });
|
|
72
|
+
});
|
|
73
|
+
ensure(new Set(result.map(value => value.id)).size === result.length, 'invalid-provider-evidence');
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
const result = [], ids = new Set(), cursors = new Set(); let cursor = null;
|
|
77
|
+
for (let page = 0; page < 10; page++) {
|
|
78
|
+
const connection = (await graphql(STATES_QUERY, { filter: { team: { id: { eq: current.owner.id } } }, cursor })).workflowStates;
|
|
79
|
+
ensure(Array.isArray(connection?.nodes) && connection.nodes.length <= 100 && typeof connection.pageInfo?.hasNextPage === 'boolean', 'invalid-provider-evidence');
|
|
80
|
+
for (const value of connection.nodes) {
|
|
81
|
+
const normalized = state(value, current.owner);
|
|
82
|
+
ensure(!ids.has(normalized.id), 'invalid-provider-evidence'); ids.add(normalized.id);
|
|
83
|
+
result.push(plain({ id: normalized.id, name: normalized.name, state: normalized, eligible: true, reason: null, metadataDigest: hash({ state: normalized, owner: current.owner }) }));
|
|
84
|
+
}
|
|
85
|
+
if (!connection.pageInfo.hasNextPage) return result;
|
|
86
|
+
cursor = connection.pageInfo.endCursor;
|
|
87
|
+
ensure(connection.nodes.length > 0 && typeof cursor === 'string' && /^[A-Za-z0-9._~+=/-]{1,512}$/.test(cursor) && !cursors.has(cursor), 'invalid-provider-evidence'); cursors.add(cursor);
|
|
88
|
+
}
|
|
89
|
+
ensure(false, 'invalid-provider-evidence');
|
|
90
|
+
}
|
|
91
|
+
const binding = { ...target, providerId: input.providerId, endpoint: input.baseUrl, mergeCommit: merged.commitSha, reviewUrl: merged.resourceUrl, assurance: 'precheck-readback' };
|
|
92
|
+
function validSaved(payload) {
|
|
93
|
+
exact(payload, [...Object.keys(binding), 'issue', 'destination']);
|
|
94
|
+
const { issue: savedIssue, destination, ...rest } = payload;
|
|
95
|
+
ensure(hash(rest) === hash(binding), 'changed-facts');
|
|
96
|
+
exact(savedIssue, ['id', 'key', 'owner', 'revision', 'state']); internal(savedIssue.id); owner(savedIssue.owner); revision(savedIssue.revision);
|
|
97
|
+
ensure(savedIssue.key === target.issueKey); exact(savedIssue.state, ['id', 'name', 'type']); internal(savedIssue.state.id); label(savedIssue.state.name); label(savedIssue.state.type);
|
|
98
|
+
exact(destination, ['id', 'name', 'state', 'eligible', 'reason', 'metadataDigest']); internal(destination.id); label(destination.name); digest(destination.metadataDigest);
|
|
99
|
+
exact(destination.state, ['id', 'name', 'type']); internal(destination.state.id); label(destination.state.name); label(destination.state.type);
|
|
100
|
+
ensure(destination.eligible === true && destination.reason === null);
|
|
101
|
+
if (!jira) ensure(destination.id === destination.state.id);
|
|
102
|
+
}
|
|
103
|
+
function prepare(payload) {
|
|
104
|
+
validSaved(payload);
|
|
105
|
+
function candidate(value) { validateCandidate(value); ensure(hash(value.repository) === hash(repository) && value.headSha === merged.headSha && (value.reviewNumber === null || value.reviewNumber === reviewNumber), 'changed-facts'); }
|
|
106
|
+
async function observe(value) {
|
|
107
|
+
candidate(value);
|
|
108
|
+
const current = await issue(payload.issue.id, payload.issue);
|
|
109
|
+
const choices = await destinations(current);
|
|
110
|
+
const selected = choices.find(choice => choice.id === payload.destination.id);
|
|
111
|
+
ensure(hash(current) === hash(payload.issue) && selected && hash(selected) === hash(payload.destination), 'changed-facts');
|
|
112
|
+
const evidenceDigest = hash({ current, selected, binding });
|
|
113
|
+
return plain({ repositoryUrl: repository.url, sourceBranch: value.sourceBranch, targetBranch: value.targetBranch, headSha: value.headSha, baseSha: merged.commitSha,
|
|
114
|
+
review: { number: reviewNumber, state: 'merged', url: merged.resourceUrl, headSha: value.headSha },
|
|
115
|
+
checks: { headSha: value.headSha, policy: 'unknown', satisfied: null, evidenceDigest },
|
|
116
|
+
reviews: { headSha: value.headSha, policy: 'unknown', satisfied: null, evidenceDigest }, observedAt: timestamp(clock()) });
|
|
117
|
+
}
|
|
118
|
+
function operation(value) { const op = plain(value); candidate(op.candidate); digest(op.digest); ensure(op.action === 'tracker-transition' && hash(op.payload) === hash(payload) && hash(op.mergeReceipt) === hash(merged), 'changed-facts'); return op; }
|
|
119
|
+
async function reconcile(value) {
|
|
120
|
+
try {
|
|
121
|
+
const op = operation(value), current = await issue(payload.issue.id, payload.issue);
|
|
122
|
+
ensure(hash(current.state) === hash(payload.destination.state), 'unverified-effect');
|
|
123
|
+
return { status: 'succeeded', receipt: plain({ status: 'succeeded', operationDigest: op.digest, headSha: op.candidate.headSha, commitSha: merged.commitSha,
|
|
124
|
+
resourceUrl: target.issueUrl, evidenceDigest: hash({ payload, observed: current, assurance: 'desired-state-observed-not-exclusive-causation' }) }) };
|
|
125
|
+
} catch { return { status: 'unknown' }; }
|
|
126
|
+
}
|
|
127
|
+
async function dispatch(value, context) {
|
|
128
|
+
const op = operation(value), ctx = plain(context); exact(ctx, ['deadline']);
|
|
129
|
+
const deadline = Date.parse(timestamp(ctx.deadline)), timely = () => ensure(Date.parse(timestamp(clock())) < deadline, 'dispatch-expired');
|
|
130
|
+
timely(); ensure(hash(payload.issue.state) !== hash(payload.destination.state), 'already-desired');
|
|
131
|
+
ensure(factsDigest(await observe(op.candidate)) === op.factsDigest, 'changed-facts');
|
|
132
|
+
const wireBody = createProviderWireBody({ provider: target.kind, action: 'tracker-transition', resourceId: payload.issue.id,
|
|
133
|
+
expectedState: payload.issue.state.id, expectedVersion: payload.issue.revision, idempotencyKey: op.digest,
|
|
134
|
+
payload: jira ? { transitionId: payload.destination.id } : { issueId: payload.issue.id, stateId: payload.destination.state.id } });
|
|
135
|
+
timely(); const remaining = deadline - Date.parse(timestamp(clock())); ensure(remaining > 0, 'dispatch-expired');
|
|
136
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), remaining);
|
|
137
|
+
try {
|
|
138
|
+
const response = await http.request({ method: 'POST', path: jira ? `/rest/api/3/issue/${payload.issue.id}/transitions` : '/graphql', wireBody, signal: controller.signal });
|
|
139
|
+
ensure(jira ? response.status === 204 : response.data && !Object.hasOwn(response.data, 'errors') && response.data.data?.issueUpdate?.success === true && response.data.data.issueUpdate.issue?.id === payload.issue.id, 'unverified-effect');
|
|
140
|
+
} finally { clearTimeout(timer); }
|
|
141
|
+
const outcome = await reconcile(op); ensure(outcome.status === 'succeeded', 'unverified-effect'); return outcome.receipt;
|
|
142
|
+
}
|
|
143
|
+
const executor = createTrustedDeliveryExecutor({ provider: repository.provider,
|
|
144
|
+
capabilities: [{ action: 'tracker-transition', conditionalHead: false, verifiesDesiredState: true, reconcile: true }], observe, dispatch, reconcile });
|
|
145
|
+
return Object.freeze({ executor, payload, alreadyDesired: hash(payload.issue.state) === hash(payload.destination.state),
|
|
146
|
+
preview: `${target.issueKey}: ${payload.issue.state.name} -> ${payload.destination.state.name} (${payload.destination.name})` });
|
|
147
|
+
}
|
|
148
|
+
if (input.persistedPayload !== undefined) {
|
|
149
|
+
const payload = plain(input.persistedPayload); validSaved(payload); await issue(payload.issue.id, payload.issue); return prepare(payload);
|
|
150
|
+
}
|
|
151
|
+
const current = await issue(target.issueKey), available = await destinations(current);
|
|
152
|
+
const status = plain({ target, current: current.state, issueRevision: current.revision, destinations: available });
|
|
153
|
+
return Object.freeze({ status, async select(destinationId) {
|
|
154
|
+
const destination = available.find(value => value.id === destinationId);
|
|
155
|
+
ensure(destination?.eligible === true, 'unsupported-transition');
|
|
156
|
+
return prepare(plain({ ...binding, issue: current, destination }));
|
|
157
|
+
} });
|
|
158
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createProviderHttpClient } from '../adapters/http.js';
|
|
2
|
+
import { captureRecord, createProviderReadBody, createProviderWireBody } from '../adapters/contract.js';
|
|
3
|
+
import { parseRepositoryRemote } from '../repositories/identity.js';
|
|
4
|
+
import { createTrustedDeliveryExecutor } from './service.js';
|
|
5
|
+
import { digest, ensure, exact, factsDigest, hash, id, plain, sha, timestamp, validateCandidate, validateReceipt } from './contract.js';
|
|
6
|
+
const KEY = /^[A-Z][A-Z0-9]{0,31}-[1-9][0-9]{0,15}$/;
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const NUMBER = /^[1-9][0-9]{0,19}$/;
|
|
9
|
+
const ISSUE_QUERY = 'query RivetDeliveryIssue($id: String!) { issue(id: $id) { id identifier url updatedAt team { id key } } }';
|
|
10
|
+
const COMMENTS_QUERY = 'query RivetDeliveryComments($id: String!, $cursor: String) { issue(id: $id) { id comments(first: 100, after: $cursor) { nodes { id body } pageInfo { hasNextPage endCursor } } } }';
|
|
11
|
+
function safeUrl(value) {
|
|
12
|
+
ensure(typeof value === 'string' && value.length <= 2048 && !/[\s<>`\[\]()]/.test(value));
|
|
13
|
+
let url;
|
|
14
|
+
try { url = new URL(value); } catch { ensure(false); }
|
|
15
|
+
ensure(url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash && url.href === value);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
function revision(value) {
|
|
19
|
+
ensure(typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value)), 'invalid-provider-evidence');
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function document(body) {
|
|
23
|
+
return { type: 'doc', version: 1, content: body.split('\n').filter(Boolean).map(text => ({type:'paragraph',content:[{type:'text',text}]})) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function createTrackerDelivery(inputConfig) {
|
|
27
|
+
ensure(inputConfig && typeof inputConfig === 'object' && !Array.isArray(inputConfig));
|
|
28
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(inputConfig))) ensure(Object.hasOwn(descriptor, 'value'));
|
|
29
|
+
const input = captureRecord(inputConfig,
|
|
30
|
+
new Set(['repository','target','mergeReceipt','deploymentReceipt','providerId','baseUrl','headers','transport','clock','timeoutMs','persistedPayload']),
|
|
31
|
+
['repository','target','mergeReceipt','deploymentReceipt','providerId','baseUrl','transport'], 'invalid-config');
|
|
32
|
+
const supplied = plain(input.repository), repository = parseRepositoryRemote(supplied.url);
|
|
33
|
+
ensure(hash(repository) === hash(supplied));
|
|
34
|
+
const target = plain(input.target);
|
|
35
|
+
exact(target,['kind','issueKey','issueUrl','requestDigest']);
|
|
36
|
+
ensure(['jira','linear'].includes(target.kind) && typeof target.issueKey === 'string' && KEY.test(target.issueKey));
|
|
37
|
+
digest(target.requestDigest); id(input.providerId);
|
|
38
|
+
ensure(typeof input.baseUrl === 'string');
|
|
39
|
+
const issueUrl = safeUrl(target.issueUrl), endpoint = safeUrl(input.baseUrl.endsWith('/') ? input.baseUrl : input.baseUrl + '/');
|
|
40
|
+
// Keep credentials scoped to the known tenant/API; no arbitrary custom hosts.
|
|
41
|
+
ensure(target.kind === 'jira'
|
|
42
|
+
? /^https:\/\/[a-z0-9][a-z0-9-]*\.atlassian\.net\/$/.test(endpoint.href)
|
|
43
|
+
&& target.issueUrl === `${endpoint.origin}/browse/${target.issueKey}`
|
|
44
|
+
: endpoint.origin === 'https://api.linear.app' && endpoint.pathname === '/'
|
|
45
|
+
&& issueUrl.origin === 'https://linear.app'
|
|
46
|
+
&& new RegExp(`^/[^/]+/issue/${target.issueKey}(?:/[^/]+)?/?$`).test(issueUrl.pathname));
|
|
47
|
+
const baseUrl = endpoint.origin;
|
|
48
|
+
const merged = plain(input.mergeReceipt);
|
|
49
|
+
validateReceipt(merged,{action:'merge',digest:digest(merged.operationDigest),candidate:{headSha:sha(merged.headSha)}});
|
|
50
|
+
safeUrl(merged.resourceUrl);
|
|
51
|
+
const reviewPrefix = `${repository.url}${repository.provider === 'github' ? '/pull/' : repository.provider === 'gitlab' ? '/-/merge_requests/' : '/pull-requests/'}`;
|
|
52
|
+
const reviewNumber = Number(merged.resourceUrl.slice(reviewPrefix.length));
|
|
53
|
+
ensure(Number.isSafeInteger(reviewNumber) && reviewNumber > 0 && merged.resourceUrl === reviewPrefix + reviewNumber);
|
|
54
|
+
let deploymentUrl = null;
|
|
55
|
+
if (input.deploymentReceipt !== null) {
|
|
56
|
+
const deployed = plain(input.deploymentReceipt);
|
|
57
|
+
validateReceipt(deployed,{action:'deploy',digest:digest(deployed.operationDigest),candidate:{headSha:merged.headSha}});
|
|
58
|
+
ensure(deployed.commitSha === merged.commitSha); safeUrl(deployed.resourceUrl); deploymentUrl = deployed.resourceUrl;
|
|
59
|
+
}
|
|
60
|
+
const clock = input.clock ?? (()=>new Date().toISOString()); ensure(typeof clock === 'function');
|
|
61
|
+
const http = createProviderHttpClient({provider:target.kind,baseUrl,transport:input.transport,maxItems:1000,maxPages:10,
|
|
62
|
+
...(input.headers === undefined ? {} : {headers:input.headers}), ...(input.timeoutMs === undefined ? {} : {timeoutMs:input.timeoutMs})});
|
|
63
|
+
async function graphql(query,variables) {
|
|
64
|
+
const response = (await http.request({method:'POST',path:'/graphql',wireBody:createProviderReadBody({provider:'linear',payload:{query,variables}})})).data;
|
|
65
|
+
ensure(response && !Object.hasOwn(response,'errors') && response.data && typeof response.data === 'object','invalid-provider-evidence');
|
|
66
|
+
return response.data;
|
|
67
|
+
}
|
|
68
|
+
const internal = value => { ensure(typeof value === 'string' && (target.kind === 'jira' ? NUMBER : UUID).test(value), 'invalid-provider-evidence'); return value; };
|
|
69
|
+
async function issue(selected, expected) {
|
|
70
|
+
if (target.kind === 'jira') {
|
|
71
|
+
const value = (await http.request({method:'GET',path:`/rest/api/3/issue/${encodeURIComponent(selected)}?fields=updated,project`})).data;
|
|
72
|
+
internal(value?.id);
|
|
73
|
+
ensure(value.key === target.issueKey && (!expected || value.id === expected)
|
|
74
|
+
&& typeof value.fields?.project?.id === 'string' && NUMBER.test(value.fields.project.id)
|
|
75
|
+
&& value.fields.project.key === target.issueKey.split('-')[0], 'changed-facts');
|
|
76
|
+
return plain({id:value.id,key:value.key,project:{id:value.fields.project.id,key:value.fields.project.key},revision:revision(value.fields.updated)});
|
|
77
|
+
}
|
|
78
|
+
const value = (await graphql(ISSUE_QUERY,{id:selected})).issue;
|
|
79
|
+
internal(value?.id);
|
|
80
|
+
ensure(value.identifier === target.issueKey && value.url === target.issueUrl && (!expected || value.id === expected)
|
|
81
|
+
&& typeof value.team?.id === 'string' && UUID.test(value.team.id)
|
|
82
|
+
&& value.team.key === target.issueKey.split('-')[0], 'changed-facts');
|
|
83
|
+
return plain({id:value.id,key:value.identifier,team:{id:value.team.id,key:value.team.key},revision:revision(value.updatedAt)});
|
|
84
|
+
}
|
|
85
|
+
const binding = {...target,providerId:input.providerId,endpoint:baseUrl,mergeCommit:merged.commitSha,reviewUrl:merged.resourceUrl,deploymentUrl};
|
|
86
|
+
let payload;
|
|
87
|
+
if (input.persistedPayload !== undefined) {
|
|
88
|
+
const stored = plain(input.persistedPayload);
|
|
89
|
+
exact(stored,[...Object.keys(binding),'issueInternalId']);
|
|
90
|
+
const {issueInternalId,...priorBinding} = stored;
|
|
91
|
+
internal(issueInternalId); ensure(hash(binding) === hash(priorBinding),'changed-facts');
|
|
92
|
+
await issue(issueInternalId,issueInternalId); payload = stored;
|
|
93
|
+
} else {
|
|
94
|
+
const source = await issue(target.issueKey);
|
|
95
|
+
payload = plain({...binding,issueInternalId:source.id});
|
|
96
|
+
}
|
|
97
|
+
const preview = `Rivet delivery update\nMerged commit: ${merged.commitSha}\nReview: ${merged.resourceUrl}${deploymentUrl ? `\nVerified deployment: ${deploymentUrl}` : ''}`;
|
|
98
|
+
function candidate(value) {
|
|
99
|
+
validateCandidate(value);
|
|
100
|
+
ensure(hash(value.repository) === hash(repository) && value.headSha === merged.headSha
|
|
101
|
+
&& (value.reviewNumber === null || value.reviewNumber === reviewNumber),'changed-facts');
|
|
102
|
+
}
|
|
103
|
+
async function observe(value) {
|
|
104
|
+
candidate(value);
|
|
105
|
+
const current = await issue(payload.issueInternalId,payload.issueInternalId), evidenceDigest = hash({current,target,payload});
|
|
106
|
+
return plain({repositoryUrl:repository.url,sourceBranch:value.sourceBranch,targetBranch:value.targetBranch,headSha:value.headSha,baseSha:merged.commitSha,
|
|
107
|
+
review:{number:reviewNumber,state:'merged',url:merged.resourceUrl,headSha:value.headSha},
|
|
108
|
+
checks:{headSha:value.headSha,policy:'unknown',satisfied:null,evidenceDigest},
|
|
109
|
+
reviews:{headSha:value.headSha,policy:'unknown',satisfied:null,evidenceDigest},observedAt:timestamp(clock())});
|
|
110
|
+
}
|
|
111
|
+
function operation(input) {
|
|
112
|
+
const op=plain(input);candidate(op.candidate);digest(op.digest);
|
|
113
|
+
ensure(op.action === 'tracker-update' && hash(op.payload) === hash(payload) && hash(op.mergeReceipt) === hash(merged),'changed-facts');
|
|
114
|
+
return op;
|
|
115
|
+
}
|
|
116
|
+
const bodyFor = op => `${preview}\n\nRivet delivery operation: ${op.digest}`;
|
|
117
|
+
async function comments() {
|
|
118
|
+
if (target.kind === 'jira') return http.paginate({path:`/rest/api/3/issue/${payload.issueInternalId}/comment?startAt=0&maxResults=100`,mode:'jira-offset',itemsKey:'comments',identityKey:'id',totalKey:'total'});
|
|
119
|
+
const output=[],seen=new Set(),ids=new Set();let cursor=null;
|
|
120
|
+
for(let page=0;page<10;page++) {
|
|
121
|
+
const value=(await graphql(COMMENTS_QUERY,{id:payload.issueInternalId,cursor})).issue;
|
|
122
|
+
ensure(value?.id === payload.issueInternalId && Array.isArray(value.comments?.nodes),'invalid-provider-evidence');
|
|
123
|
+
const {nodes,pageInfo}=value.comments;
|
|
124
|
+
ensure(nodes.length <= 100 && pageInfo && typeof pageInfo.hasNextPage === 'boolean','invalid-provider-evidence');
|
|
125
|
+
for(const item of nodes) {ensure(typeof item?.id === 'string' && UUID.test(item.id) && !ids.has(item.id),'invalid-provider-evidence');ids.add(item.id);output.push(item);}
|
|
126
|
+
if(pageInfo.hasNextPage === false)return output;
|
|
127
|
+
ensure(nodes.length > 0 && typeof pageInfo.endCursor === 'string' && /^[A-Za-z0-9._~+=/-]{1,512}$/.test(pageInfo.endCursor) && !seen.has(pageInfo.endCursor),'invalid-provider-evidence');
|
|
128
|
+
cursor=pageInfo.endCursor;seen.add(cursor);
|
|
129
|
+
}
|
|
130
|
+
ensure(false,'invalid-provider-evidence');
|
|
131
|
+
}
|
|
132
|
+
async function reconcile(input) {
|
|
133
|
+
try {
|
|
134
|
+
const op=operation(input),current=await issue(payload.issueInternalId,payload.issueInternalId),all=await comments();
|
|
135
|
+
const expected=target.kind === 'jira' ? document(bodyFor(op)) : bodyFor(op);
|
|
136
|
+
for(const item of all) ensure(typeof item?.id === 'string' && (target.kind==='jira'?NUMBER:UUID).test(item.id)
|
|
137
|
+
&& (target.kind==='jira' ? item.body?.type==='doc' && item.body.version===1 && Array.isArray(item.body.content) : typeof item.body==='string'),'invalid-provider-evidence');
|
|
138
|
+
const marker = `Rivet delivery operation: ${op.digest}`;
|
|
139
|
+
const matches=all.filter(item=>JSON.stringify(item.body).includes(marker));
|
|
140
|
+
ensure(matches.length===1 && hash(matches[0].body)===hash(expected),'unverified-effect');
|
|
141
|
+
return {status:'succeeded',receipt:plain({status:'succeeded',operationDigest:op.digest,headSha:op.candidate.headSha,commitSha:merged.commitSha,
|
|
142
|
+
resourceUrl:target.issueUrl,evidenceDigest:hash({issue:current,comment:matches[0],payload})})};
|
|
143
|
+
}catch{return {status:'unknown'}}
|
|
144
|
+
}
|
|
145
|
+
async function dispatch(input,context) {
|
|
146
|
+
const op=operation(input),ctx=plain(context);exact(ctx,['deadline']);const deadline=Date.parse(timestamp(ctx.deadline));
|
|
147
|
+
const timely=()=>ensure(Date.parse(timestamp(clock()))<deadline,'dispatch-expired');timely();
|
|
148
|
+
ensure(factsDigest(await observe(op.candidate))===op.factsDigest,'changed-facts');
|
|
149
|
+
const wireBody=createProviderWireBody({provider:target.kind,action:target.kind==='jira'?'comment':'delivery-comment',resourceId:payload.issueInternalId,
|
|
150
|
+
expectedState:'merged',expectedVersion:merged.commitSha,idempotencyKey:op.digest,
|
|
151
|
+
payload:target.kind==='jira'?{body:document(bodyFor(op))}:{issueId:payload.issueInternalId,body:bodyFor(op)}});
|
|
152
|
+
timely();const remaining=deadline-Date.parse(timestamp(clock()));ensure(remaining>0,'dispatch-expired');
|
|
153
|
+
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),remaining);
|
|
154
|
+
try {
|
|
155
|
+
const response=(await http.request({method:'POST',path:target.kind==='jira'?`/rest/api/3/issue/${payload.issueInternalId}/comment`:'/graphql',wireBody,signal:controller.signal})).data;
|
|
156
|
+
if(target.kind==='linear')ensure(response && !Object.hasOwn(response,'errors') && response.data?.commentCreate?.success===true,'unverified-effect');
|
|
157
|
+
} finally {clearTimeout(timer)}
|
|
158
|
+
const result=await reconcile(op);ensure(result.status==='succeeded','unverified-effect');return result.receipt;
|
|
159
|
+
}
|
|
160
|
+
// conditionalHead binds comment content to the confirmed merge commit; it does
|
|
161
|
+
// not claim atomic issue version checks or native comment idempotency.
|
|
162
|
+
const executor=createTrustedDeliveryExecutor({provider:repository.provider,capabilities:[{action:'tracker-update',conditionalHead:true,reconcile:true}],observe,dispatch,reconcile});
|
|
163
|
+
return Object.freeze({executor,payload,preview});
|
|
164
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { runArgv } from './tools.js';
|
|
4
|
+
|
|
5
|
+
async function git(runner, cwd, args) {
|
|
6
|
+
try {
|
|
7
|
+
return await runner('git', args, {
|
|
8
|
+
cwd,
|
|
9
|
+
shell: false,
|
|
10
|
+
timeoutMs: 3_000,
|
|
11
|
+
maxOutputBytes: 32 * 1024,
|
|
12
|
+
});
|
|
13
|
+
} catch {
|
|
14
|
+
return { code: 1, stdout: '', stderr: '' };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pathsFromWorktrees(output) {
|
|
19
|
+
if (typeof output !== 'string' || !output.includes('\0')) return null;
|
|
20
|
+
const records = output.split('\0\0').filter(Boolean);
|
|
21
|
+
const paths = [];
|
|
22
|
+
for (const record of records) {
|
|
23
|
+
const fields = record.split('\0').filter(Boolean);
|
|
24
|
+
if (!fields[0]?.startsWith('worktree ') || fields[0].length === 'worktree '.length) return null;
|
|
25
|
+
paths.push(resolve(fields[0].slice('worktree '.length)));
|
|
26
|
+
}
|
|
27
|
+
return paths.length > 0 ? paths : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function discoverGit(projectRoot, options = {}) {
|
|
31
|
+
const cwd = resolve(projectRoot);
|
|
32
|
+
const runner = options.runner ?? runArgv;
|
|
33
|
+
const rootResult = await git(runner, cwd, ['rev-parse', '--show-toplevel']);
|
|
34
|
+
if (rootResult.code !== 0) {
|
|
35
|
+
return {
|
|
36
|
+
repository: false,
|
|
37
|
+
root: null,
|
|
38
|
+
currentBranch: null,
|
|
39
|
+
defaultBranch: null,
|
|
40
|
+
detached: false,
|
|
41
|
+
dirty: false,
|
|
42
|
+
baseFreshness: 'not_checked',
|
|
43
|
+
worktrees: [],
|
|
44
|
+
occupiedCandidatePaths: [],
|
|
45
|
+
worktreeCheck: { checked: false, error: 'not_repository' },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const [branchResult, statusResult, defaultResult, worktreeResult] = await Promise.all([
|
|
49
|
+
git(runner, cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
50
|
+
git(runner, cwd, ['status', '--porcelain']),
|
|
51
|
+
git(runner, cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']),
|
|
52
|
+
git(runner, cwd, ['worktree', 'list', '--porcelain', '-z']),
|
|
53
|
+
]);
|
|
54
|
+
const currentBranch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
|
|
55
|
+
const remoteHead = defaultResult.code === 0 ? defaultResult.stdout.trim() : '';
|
|
56
|
+
const defaultBranch = options.defaultBranch ?? (remoteHead.replace(/^origin\//, '')
|
|
57
|
+
|| (currentBranch === 'main' || currentBranch === 'master' ? currentBranch : 'main'));
|
|
58
|
+
const defaultBranchSource = options.defaultBranch !== undefined ? 'configuration' : remoteHead
|
|
59
|
+
? 'remote_tracking' : (currentBranch === 'main' || currentBranch === 'master') ? 'current_branch' : 'default';
|
|
60
|
+
const freshnessResult = await git(runner, cwd, [
|
|
61
|
+
'rev-list', '--left-right', '--count', `${defaultBranch}...refs/remotes/origin/${defaultBranch}`,
|
|
62
|
+
]);
|
|
63
|
+
let baseFreshness = 'not_checked';
|
|
64
|
+
if (freshnessResult.code === 0 && /^\d+\s+\d+$/.test(freshnessResult.stdout.trim())) {
|
|
65
|
+
const [ahead = 0, behind = 0] = freshnessResult.stdout.trim().split(/\s+/).map(Number);
|
|
66
|
+
baseFreshness = behind > 0 ? 'behind' : ahead > 0 ? 'ahead' : 'fresh';
|
|
67
|
+
}
|
|
68
|
+
const worktrees = worktreeResult.code === 0 ? pathsFromWorktrees(worktreeResult.stdout) : null;
|
|
69
|
+
const worktreeError = worktreeResult.timedOut === true
|
|
70
|
+
? 'timeout' : worktreeResult.code !== 0 ? 'unavailable' : worktrees === null ? 'malformed' : null;
|
|
71
|
+
const candidates = (options.candidatePaths ?? []).map(path => resolve(path));
|
|
72
|
+
return {
|
|
73
|
+
repository: true,
|
|
74
|
+
root: resolve(rootResult.stdout.trim()),
|
|
75
|
+
currentBranch,
|
|
76
|
+
defaultBranch,
|
|
77
|
+
defaultBranchSource,
|
|
78
|
+
detached: currentBranch === null,
|
|
79
|
+
dirty: statusResult.code !== 0 || statusResult.stdout.trim().length > 0,
|
|
80
|
+
baseFreshness,
|
|
81
|
+
worktrees: worktrees ?? [],
|
|
82
|
+
occupiedCandidatePaths: worktrees === null ? null : candidates.filter(path => worktrees.includes(path)),
|
|
83
|
+
worktreeCheck: worktreeError ? { checked: false, error: worktreeError } : { checked: true },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {basename,join} from 'node:path';
|
|
2
|
+
import {compileProjectCommands} from '../config/commands.js';
|
|
3
|
+
|
|
4
|
+
export class ProjectChecksRequiredError extends Error {
|
|
5
|
+
constructor(message = 'No project verification commands were detected. Run interactive rivet setup --write to choose a check, or pass --checks-json with your project commands, for example: {"test":["python3","-m","pytest"]}. No files were written.') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = 'MISSING_CONFIGURATION';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseProjectChecks(value) {
|
|
12
|
+
if (value === undefined) return undefined;
|
|
13
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > 16384) throw new ProjectChecksRequiredError('Option --checks-json must be a bounded JSON object of project check commands.');
|
|
14
|
+
try { return JSON.parse(value); }
|
|
15
|
+
catch { throw new ProjectChecksRequiredError('Option --checks-json must contain valid JSON, for example: {"test":["python3","-m","pytest"]}.'); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseProjectDependencies(value) {
|
|
19
|
+
if (value === undefined) return undefined;
|
|
20
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > 16384) throw new ProjectChecksRequiredError('Option --dependencies-json must be a bounded JSON dependency plan.');
|
|
21
|
+
try {
|
|
22
|
+
const parsed=JSON.parse(value);
|
|
23
|
+
if(!parsed || typeof parsed!=='object' || Array.isArray(parsed))throw new Error('Invalid dependency object');
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch { throw new ProjectChecksRequiredError('Option --dependencies-json must contain valid JSON with inputs, provides, and ordered steps.'); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Tokenize one argv command for the setup prompt. No expansion or shell dispatch.
|
|
30
|
+
export function parseCheckCommand(value) {
|
|
31
|
+
if(typeof value!=='string' || value.length>16384 || /[\u0000-\u001f\u007f]/.test(value)) throw new ProjectChecksRequiredError('Enter one bounded verification command without control characters.');
|
|
32
|
+
const argv=[];let token='',quote=null,escaped=false,started=false;
|
|
33
|
+
for(const char of value.trim()) {
|
|
34
|
+
if(escaped) {token+=char;escaped=false;started=true;continue;}
|
|
35
|
+
if(char==='\\' && quote!=="'") {escaped=true;started=true;continue;}
|
|
36
|
+
if(quote) {if(char===quote)quote=null;else token+=char;started=true;continue;}
|
|
37
|
+
if(char==='"'||char==="'") {quote=char;started=true;continue;}
|
|
38
|
+
if(/\s/.test(char)) {if(started){argv.push(token);token='';started=false;}continue;}
|
|
39
|
+
if(/[;&|<>`]/.test(char)) throw new ProjectChecksRequiredError('Enter one command without shell operators. Configure separate argv steps with --checks-json for multiple checks.');
|
|
40
|
+
token+=char;started=true;
|
|
41
|
+
}
|
|
42
|
+
if(quote||escaped)throw new ProjectChecksRequiredError('The verification command has an unfinished quote or escape.');
|
|
43
|
+
if(started)argv.push(token);
|
|
44
|
+
if(!argv.length)throw new ProjectChecksRequiredError();
|
|
45
|
+
return argv;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function discoverPortableProject(root, {fs, inspect, read, checks}) {
|
|
49
|
+
const metadata = new Map();
|
|
50
|
+
let totalBytes=0;
|
|
51
|
+
for (const name of ['manage.py','pyproject.toml','requirements.txt','setup.cfg','setup.py','Pipfile','uv.lock','poetry.lock','go.mod','Cargo.toml','Makefile','pom.xml','build.gradle','composer.json','Gemfile','AGENTS.md']) {
|
|
52
|
+
if(['uv.lock','poetry.lock'].includes(name)) {
|
|
53
|
+
const entry=fs.lstatSync(join(root,name),{throwIfNoEntry:false});
|
|
54
|
+
if(entry) {if(!entry.isFile()||entry.isSymbolicLink())throw new ProjectChecksRequiredError('Project lock metadata must be a regular file.');metadata.set(name,'');}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const file=inspect(root,name,fs,false);
|
|
58
|
+
if(file) {
|
|
59
|
+
totalBytes+=file.size;
|
|
60
|
+
if(totalBytes>2*1024*1024)throw new ProjectChecksRequiredError('Project metadata exceeds the discovery read limit. Use smaller metadata files.');
|
|
61
|
+
metadata.set(name,read(file,fs));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let framework='other', language='other', packageManager='other', source='explicit project checks';
|
|
65
|
+
let commands={};
|
|
66
|
+
const group=argv=>({steps:[{cwd:'.',argv}]});
|
|
67
|
+
if(metadata.has('manage.py') || ['pyproject.toml','requirements.txt','setup.cfg','setup.py','Pipfile'].some(name=>metadata.has(name))) {
|
|
68
|
+
language='python';packageManager=metadata.has('uv.lock')?'uv':metadata.has('poetry.lock')?'poetry':'pip';
|
|
69
|
+
if(metadata.has('manage.py')) {
|
|
70
|
+
framework='django';source='manage.py (Django convention; review before use)';
|
|
71
|
+
commands={check:group(['python3','manage.py','check']),test:group(['python3','manage.py','test'])};
|
|
72
|
+
} else if(['pyproject.toml','requirements.txt','setup.cfg'].some(name=>/\bpytest\b/.test(metadata.get(name)??''))) {
|
|
73
|
+
source='Python metadata (pytest convention; review before use)';
|
|
74
|
+
commands={test:group(['python3','-m','pytest'])};
|
|
75
|
+
}
|
|
76
|
+
} else if(metadata.has('go.mod')) {
|
|
77
|
+
language='go';packageManager='go';source='go.mod (Go convention; review before use)';
|
|
78
|
+
commands={build:group(['go','build','./...']),test:group(['go','test','./...'])};
|
|
79
|
+
} else if(metadata.has('Cargo.toml')) {
|
|
80
|
+
language='rust';packageManager='cargo';source='Cargo.toml (Cargo convention; review before use)';
|
|
81
|
+
commands={build:group(['cargo','build']),test:group(['cargo','test'])};
|
|
82
|
+
}
|
|
83
|
+
if(checks !== undefined) {
|
|
84
|
+
if(!checks || typeof checks!=='object' || Array.isArray(checks)) throw new ProjectChecksRequiredError('Project checks must be a JSON object mapping test/check/build/lint/typecheck to argv arrays or step groups.');
|
|
85
|
+
commands=Object.fromEntries(Object.entries(checks).map(([key,value])=>[key,Array.isArray(value)?group(value):value]));
|
|
86
|
+
source='explicit --checks-json';
|
|
87
|
+
}
|
|
88
|
+
let dependencies;
|
|
89
|
+
if (language === 'python' && metadata.has('requirements.txt') && checks === undefined) {
|
|
90
|
+
const python='./.rivet-deps/venv/bin/python';
|
|
91
|
+
dependencies={inputs:['requirements.txt'],provides:[python],steps:[
|
|
92
|
+
{cwd:'.',argv:['python3','-m','venv','.rivet-deps/venv']},
|
|
93
|
+
{cwd:'.',argv:[python,'-m','pip','--isolated','install','--require-virtualenv','--no-user','-r','requirements.txt']},
|
|
94
|
+
]};
|
|
95
|
+
for(const group of Object.values(commands)) for(const step of group.steps) if(step.argv[0]==='python3')step.argv[0]=python;
|
|
96
|
+
}
|
|
97
|
+
if(!commands.test && !commands.check) throw new ProjectChecksRequiredError();
|
|
98
|
+
const name=basename(root).normalize('NFKC').slice(0,120);
|
|
99
|
+
const id=name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,60).replace(/-$/,'');
|
|
100
|
+
const projectId=(/^[a-z]/.test(id)?id:`project-${id||'local'}`).slice(0,64).replace(/-$/,'');
|
|
101
|
+
const proposal={schemaVersion:3,id:projectId,name,stack:{framework,language,packageManager},commands,...(dependencies?{dependencies}:{})};
|
|
102
|
+
try { compileProjectCommands(proposal); }
|
|
103
|
+
catch { throw new ProjectChecksRequiredError('Project checks are invalid. Use bounded argv arrays with executable names, safe relative working directories, and at least one test or check command. Shell commands are not supported.'); }
|
|
104
|
+
const provenance={id:'project directory basename',name:'project directory basename','stack.framework':source,'stack.language':source,'stack.packageManager':source,'features.storybook':'not detected','features.playwright':'not detected'};
|
|
105
|
+
for(const [key,group] of Object.entries(commands)) group.steps.forEach((_,index)=>{provenance[`commands.${key}.steps[${index}]`]=source;});
|
|
106
|
+
if(dependencies)provenance.dependencies='requirements.txt (isolated Python environment proposal; review before installation)';
|
|
107
|
+
return {root,proposal,inspectedFiles:[...metadata.keys()],features:{storybook:false,playwright:false},architectureHints:metadata.has('AGENTS.md')?['AGENTS.md']:[],existingConfig:fs.existsSync(`${root}/.rivet`),warnings:[{code:'environment-preparation',message:dependencies?'Review the proposed dependency steps. Installation requires separate approval in each isolated checkout.':'Review the proposed commands. Configure dependency installation steps when this project needs environment preparation.'}],unresolved:[],provenance};
|
|
108
|
+
}
|