@abelxiaoxing/cadence 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -13
- package/agents/design-explorer.md +17 -11
- package/agents/diagnosis-worker.md +9 -1
- package/agents/implementation-worker.md +3 -1
- package/config/plan-draft.example.json +122 -0
- package/config/plan-draft.multiple-tasks.example.json +345 -0
- package/package.json +9 -5
- package/prompts/abel-design.md +141 -38
- package/prompts/abel-diagnose.md +10 -1
- package/prompts/abel-implement.md +118 -20
- package/prompts/abel-init.md +9 -2
- package/src/activity-contracts.ts +82 -0
- package/src/apply-transaction.ts +16 -14
- package/src/artifact-store.ts +17 -0
- package/src/candidate-artifact.ts +143 -0
- package/src/candidate-patch.ts +33 -12
- package/src/candidate-workspace.ts +74 -0
- package/src/canonical.ts +16 -0
- package/src/change-contract-schema.ts +191 -0
- package/src/change-contract.ts +273 -0
- package/src/change-verification.ts +896 -0
- package/src/child-model.ts +129 -0
- package/src/child-session.ts +296 -328
- package/src/contracts.ts +325 -109
- package/src/control-contracts.ts +51 -1
- package/src/delivery-compiler.ts +241 -141
- package/src/delivery-revision.ts +42 -0
- package/src/design-control.ts +182 -15
- package/src/design-diagnostics.ts +150 -0
- package/src/design-journal.ts +216 -72
- package/src/durable-contracts.ts +210 -0
- package/src/durable-workflow.ts +963 -0
- package/src/evidence-draft.ts +122 -0
- package/src/implement-graph.ts +14 -16
- package/src/implement-plan.ts +70 -0
- package/src/index.ts +341 -1405
- package/src/isolation-backend.ts +84 -23
- package/src/model-source.ts +22 -0
- package/src/package-candidate.ts +224 -0
- package/src/package-delivery.ts +486 -0
- package/src/package-verification.ts +509 -0
- package/src/package-workflow.ts +253 -0
- package/src/packet-runtime.ts +27 -48
- package/src/parent-provider.ts +86 -198
- package/src/phase-execution.ts +2248 -0
- package/src/pi-adapter.ts +19 -0
- package/src/plan-draft-summary.ts +146 -0
- package/src/plan-draft.ts +493 -0
- package/src/route-policy.ts +1 -11
- package/src/run-state.ts +4 -16
- package/src/run-store.ts +5 -144
- package/src/scoped-grep-worker.mjs +23 -0
- package/src/scoped-tools.ts +312 -116
- package/src/sqlite-schema.ts +171 -0
- package/src/storage-schema.ts +352 -0
- package/src/subagent-activity.ts +46 -67
- package/src/submit-tool.ts +40 -61
- package/src/task-ledger.ts +14 -55
- package/src/transport-budget.ts +32 -0
- package/src/verification-capability.ts +298 -102
- package/src/verification-environment.ts +213 -0
- package/src/verification-identity.ts +77 -0
- package/src/worker-broker.ts +134 -91
- package/src/workflow-engine.ts +28 -8285
- package/src/workflow-policy.ts +1388 -0
- package/src/workflow-recovery-policy.ts +95 -0
- package/src/workflow-scheduling.ts +105 -0
- package/src/workflow-state-machine.ts +4086 -0
- package/src/workflow-status.ts +384 -0
- package/src/workspace-io-entry.mjs +58 -0
- package/src/workspace-io-worker.mjs +991 -0
- package/src/workspace-io.ts +84 -0
- package/src/workspace-store.ts +125 -19
- package/skills/abel-workflow/SKILL.md +0 -205
- package/src/empty-resource-loader.ts +0 -38
- package/src/parent-payload-bridge.ts +0 -523
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type PacketActivityState =
|
|
2
|
+
| "queued"
|
|
3
|
+
| "preparing"
|
|
4
|
+
| "connecting"
|
|
5
|
+
| "waiting-first-response"
|
|
6
|
+
| "running"
|
|
7
|
+
| "retrying"
|
|
8
|
+
| "completed"
|
|
9
|
+
| "failed"
|
|
10
|
+
| "cancelled"
|
|
11
|
+
| "timed-out";
|
|
12
|
+
|
|
13
|
+
export type PacketFailureReason =
|
|
14
|
+
| "subagent failed"
|
|
15
|
+
| "subagent cancelled"
|
|
16
|
+
| "phase timed out";
|
|
17
|
+
|
|
18
|
+
export interface PacketActivityEvent {
|
|
19
|
+
state: PacketActivityState;
|
|
20
|
+
requestId: string;
|
|
21
|
+
role: string;
|
|
22
|
+
phase: string;
|
|
23
|
+
objective: string;
|
|
24
|
+
sequence: number;
|
|
25
|
+
attempt?: number;
|
|
26
|
+
maxAttempts?: number;
|
|
27
|
+
code?: string;
|
|
28
|
+
wait?: string;
|
|
29
|
+
failureReason?: PacketFailureReason;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PacketActivityObserver = (
|
|
33
|
+
event: PacketActivityEvent,
|
|
34
|
+
) => void | Promise<void>;
|
|
35
|
+
|
|
36
|
+
export const WORKFLOW_ACTIVITY_STATES = [
|
|
37
|
+
"queued",
|
|
38
|
+
"preparing",
|
|
39
|
+
"connecting",
|
|
40
|
+
"waiting-first-response",
|
|
41
|
+
"running",
|
|
42
|
+
"validating",
|
|
43
|
+
"retrying",
|
|
44
|
+
"verifying",
|
|
45
|
+
"paused",
|
|
46
|
+
"approval-needed",
|
|
47
|
+
"applying",
|
|
48
|
+
"recovering",
|
|
49
|
+
"operation-cancelled",
|
|
50
|
+
"discarded",
|
|
51
|
+
"rejected",
|
|
52
|
+
"completed",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
export type WorkflowActivityState = (typeof WORKFLOW_ACTIVITY_STATES)[number];
|
|
56
|
+
export type ActivityState = PacketActivityState | WorkflowActivityState;
|
|
57
|
+
export type TerminalActivityState =
|
|
58
|
+
| Exclude<
|
|
59
|
+
PacketActivityState,
|
|
60
|
+
| "queued"
|
|
61
|
+
| "preparing"
|
|
62
|
+
| "connecting"
|
|
63
|
+
| "waiting-first-response"
|
|
64
|
+
| "running"
|
|
65
|
+
| "retrying"
|
|
66
|
+
>
|
|
67
|
+
| WorkflowActivityState;
|
|
68
|
+
|
|
69
|
+
export interface WorkflowActivityUpdate {
|
|
70
|
+
state: WorkflowActivityState;
|
|
71
|
+
stage?: "abel-design" | "abel-implement";
|
|
72
|
+
runId?: string;
|
|
73
|
+
change?: string;
|
|
74
|
+
taskId?: string;
|
|
75
|
+
phase?: string;
|
|
76
|
+
objective?: string;
|
|
77
|
+
code?: string;
|
|
78
|
+
attempt?: number;
|
|
79
|
+
maxAttempts?: number;
|
|
80
|
+
wait?: string;
|
|
81
|
+
legalCommands?: string[];
|
|
82
|
+
}
|
package/src/apply-transaction.ts
CHANGED
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { DatabaseSync } from "node:sqlite";
|
|
23
|
-
|
|
24
23
|
import type { ArtifactStore } from "./artifact-store.ts";
|
|
24
|
+
import { compareCanonicalStrings } from "./canonical.ts";
|
|
25
25
|
import { isValidRelativePath } from "./contracts.ts";
|
|
26
26
|
import { observeSafePath } from "./safe-path.ts";
|
|
27
|
+
import { configureSqlite, ensureSqliteSchema } from "./sqlite-schema.ts";
|
|
28
|
+
import { APPLY_SCHEMA } from "./storage-schema.ts";
|
|
27
29
|
import type {
|
|
28
30
|
WorkspaceEntry,
|
|
29
31
|
WorkspaceRevision,
|
|
@@ -548,7 +550,11 @@ export async function verifyCumulativeRevision(
|
|
|
548
550
|
);
|
|
549
551
|
chmodSync(temporary, 0o700);
|
|
550
552
|
try {
|
|
551
|
-
input.workspaceStore.
|
|
553
|
+
await input.workspaceStore.materializeAsync(
|
|
554
|
+
revision.revisionId,
|
|
555
|
+
temporary,
|
|
556
|
+
input.signal,
|
|
557
|
+
);
|
|
552
558
|
if (input.signal?.aborted) {
|
|
553
559
|
return {
|
|
554
560
|
ok: false,
|
|
@@ -629,16 +635,8 @@ export class ApplyTransaction {
|
|
|
629
635
|
throw new Error("transaction-database-path-invalid");
|
|
630
636
|
}
|
|
631
637
|
this.#database = new DatabaseSync(databasePath);
|
|
632
|
-
this.#database
|
|
633
|
-
|
|
634
|
-
);
|
|
635
|
-
this.#database.exec(`
|
|
636
|
-
CREATE TABLE IF NOT EXISTS apply_transaction_journal (
|
|
637
|
-
transaction_id TEXT PRIMARY KEY,
|
|
638
|
-
journal_json TEXT NOT NULL,
|
|
639
|
-
journal_hash TEXT NOT NULL
|
|
640
|
-
) STRICT;
|
|
641
|
-
`);
|
|
638
|
+
configureSqlite(this.#database);
|
|
639
|
+
ensureSqliteSchema(this.#database, APPLY_SCHEMA);
|
|
642
640
|
chmodSync(databasePath, 0o600);
|
|
643
641
|
}
|
|
644
642
|
|
|
@@ -986,7 +984,7 @@ export class ApplyTransaction {
|
|
|
986
984
|
const deepestFirst = [...journal.createdDirectories].sort(
|
|
987
985
|
(left, right) =>
|
|
988
986
|
right.split("/").length - left.split("/").length ||
|
|
989
|
-
right
|
|
987
|
+
compareCanonicalStrings(right, left),
|
|
990
988
|
);
|
|
991
989
|
for (const relative of deepestFirst) {
|
|
992
990
|
const observation = observeSafePath(journal.consumerRoot, relative);
|
|
@@ -1244,7 +1242,11 @@ export class ApplyTransaction {
|
|
|
1244
1242
|
path.join(this.root, "post-apply-verification-"),
|
|
1245
1243
|
);
|
|
1246
1244
|
try {
|
|
1247
|
-
this.#workspaces.
|
|
1245
|
+
await this.#workspaces.materializeAsync(
|
|
1246
|
+
journal.finalRevisionId,
|
|
1247
|
+
isolatedRoot,
|
|
1248
|
+
signal,
|
|
1249
|
+
);
|
|
1248
1250
|
const execution = Promise.resolve(
|
|
1249
1251
|
this.#hooks.postApply({
|
|
1250
1252
|
transactionId,
|
package/src/artifact-store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
chmodSync,
|
|
4
4
|
closeSync,
|
|
5
5
|
constants,
|
|
6
|
+
copyFileSync,
|
|
6
7
|
fsyncSync,
|
|
7
8
|
lstatSync,
|
|
8
9
|
mkdirSync,
|
|
@@ -160,6 +161,22 @@ export class ArtifactStore {
|
|
|
160
161
|
return Buffer.from(bytes);
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** Clone when supported; always use a distinct inode and verify the copied bytes. */
|
|
165
|
+
copyVerified(hash: string, destination: string): number {
|
|
166
|
+
const source = this.#blobPath(hash);
|
|
167
|
+
const stat = lstatSync(source, { throwIfNoEntry: false });
|
|
168
|
+
if (!stat?.isFile() || stat.isSymbolicLink())
|
|
169
|
+
throw new Error("artifact-unavailable");
|
|
170
|
+
copyFileSync(
|
|
171
|
+
source,
|
|
172
|
+
destination,
|
|
173
|
+
constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE,
|
|
174
|
+
);
|
|
175
|
+
const bytes = readFileSync(destination);
|
|
176
|
+
if (digest(bytes) !== hash) throw new Error("artifact-integrity-invalid");
|
|
177
|
+
return bytes.byteLength;
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
referenceCount(hash: string): number {
|
|
164
181
|
requireHash(hash);
|
|
165
182
|
const referencePath = this.#referencePath(hash);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { diffWritePaths } from "./contracts.ts";
|
|
3
|
+
import {
|
|
4
|
+
type BeginCandidateInput,
|
|
5
|
+
TASK_LEDGER_LIMITS,
|
|
6
|
+
type TaskLedger,
|
|
7
|
+
} from "./task-ledger.ts";
|
|
8
|
+
import type { DurableCandidateProposal } from "./workflow-policy.ts";
|
|
9
|
+
|
|
10
|
+
type ArtifactProposal = Extract<
|
|
11
|
+
DurableCandidateProposal,
|
|
12
|
+
{ kind: "candidate" | "sealed-candidate" }
|
|
13
|
+
>;
|
|
14
|
+
interface CandidateArtifactInput {
|
|
15
|
+
ledger: TaskLedger;
|
|
16
|
+
identity: BeginCandidateInput;
|
|
17
|
+
proposal: ArtifactProposal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sha256 = (bytes: Uint8Array) =>
|
|
21
|
+
createHash("sha256").update(bytes).digest("hex");
|
|
22
|
+
|
|
23
|
+
/** Inspect the same sealed bytes for normal phases and repairs. Callers own recovery policy. */
|
|
24
|
+
export function inspectCandidateArtifact({
|
|
25
|
+
ledger,
|
|
26
|
+
identity,
|
|
27
|
+
proposal,
|
|
28
|
+
approvedPaths,
|
|
29
|
+
}: CandidateArtifactInput & { approvedPaths: readonly string[] }):
|
|
30
|
+
| { ok: true; bytes: Buffer; paths: string[] }
|
|
31
|
+
| {
|
|
32
|
+
ok: false;
|
|
33
|
+
code: "candidate-diff-invalid" | "write-set-mismatch";
|
|
34
|
+
stage: string;
|
|
35
|
+
} {
|
|
36
|
+
const invalid = (stage: string) => ({
|
|
37
|
+
ok: false as const,
|
|
38
|
+
code: "candidate-diff-invalid" as const,
|
|
39
|
+
stage,
|
|
40
|
+
});
|
|
41
|
+
ledger.beginCandidate(identity);
|
|
42
|
+
let bytes: Buffer;
|
|
43
|
+
if (proposal.kind === "sealed-candidate") {
|
|
44
|
+
if (
|
|
45
|
+
proposal.candidateId !== identity.candidateId ||
|
|
46
|
+
!/^[a-f0-9]{64}$/u.test(proposal.artifactHash) ||
|
|
47
|
+
!Number.isSafeInteger(proposal.bytes) ||
|
|
48
|
+
proposal.bytes < 1 ||
|
|
49
|
+
!Array.isArray(proposal.paths)
|
|
50
|
+
)
|
|
51
|
+
return invalid("candidate-seal");
|
|
52
|
+
bytes = Buffer.from(ledger.readSealedCandidate(identity.candidateId));
|
|
53
|
+
if (
|
|
54
|
+
bytes.length !== proposal.bytes ||
|
|
55
|
+
sha256(bytes) !== proposal.artifactHash
|
|
56
|
+
) {
|
|
57
|
+
throw new Error("candidate-seal-integrity-invalid");
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
if (
|
|
61
|
+
!(proposal.bytes instanceof Uint8Array) ||
|
|
62
|
+
proposal.bytes.length === 0
|
|
63
|
+
) {
|
|
64
|
+
return invalid("candidate-submit");
|
|
65
|
+
}
|
|
66
|
+
bytes = Buffer.from(proposal.bytes);
|
|
67
|
+
}
|
|
68
|
+
let paths: string[];
|
|
69
|
+
try {
|
|
70
|
+
paths = diffWritePaths(
|
|
71
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
72
|
+
).paths;
|
|
73
|
+
} catch {
|
|
74
|
+
return invalid("candidate-diff");
|
|
75
|
+
}
|
|
76
|
+
if (paths.some((relative) => !approvedPaths.includes(relative))) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
code: "write-set-mismatch",
|
|
80
|
+
stage: "candidate-boundary",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
proposal.kind === "sealed-candidate" &&
|
|
85
|
+
JSON.stringify([...proposal.paths].sort()) !==
|
|
86
|
+
JSON.stringify([...paths].sort())
|
|
87
|
+
) {
|
|
88
|
+
return invalid("candidate-seal");
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, bytes, paths };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Seal after isolated patch/dependency checks, before any phase verification. */
|
|
94
|
+
export function sealCandidateArtifact({
|
|
95
|
+
ledger,
|
|
96
|
+
identity,
|
|
97
|
+
proposal,
|
|
98
|
+
bytes,
|
|
99
|
+
}: CandidateArtifactInput & { bytes: Buffer }):
|
|
100
|
+
| { ok: true; artifactHash: string }
|
|
101
|
+
| { ok: false; kind: "paused" | "retryable"; code: string; stage: string } {
|
|
102
|
+
if (proposal.kind === "sealed-candidate")
|
|
103
|
+
return { ok: true, artifactHash: proposal.artifactHash };
|
|
104
|
+
let sequence = 0;
|
|
105
|
+
for (
|
|
106
|
+
let offset = 0;
|
|
107
|
+
offset < bytes.length;
|
|
108
|
+
offset += TASK_LEDGER_LIMITS.maxSegmentBytes
|
|
109
|
+
) {
|
|
110
|
+
const segment = bytes.subarray(
|
|
111
|
+
offset,
|
|
112
|
+
Math.min(offset + TASK_LEDGER_LIMITS.maxSegmentBytes, bytes.length),
|
|
113
|
+
);
|
|
114
|
+
const accepted = ledger.appendCandidateSegment({
|
|
115
|
+
...identity,
|
|
116
|
+
sequence,
|
|
117
|
+
bytes: segment,
|
|
118
|
+
segmentHash: sha256(segment),
|
|
119
|
+
});
|
|
120
|
+
if (!accepted.ok)
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
kind: "paused",
|
|
124
|
+
code: accepted.code,
|
|
125
|
+
stage: "candidate-segment",
|
|
126
|
+
};
|
|
127
|
+
sequence++;
|
|
128
|
+
}
|
|
129
|
+
const sealed = ledger.sealCandidate({
|
|
130
|
+
...identity,
|
|
131
|
+
segmentCount: sequence,
|
|
132
|
+
totalBytes: bytes.length,
|
|
133
|
+
candidateHash: sha256(bytes),
|
|
134
|
+
});
|
|
135
|
+
return sealed.ok
|
|
136
|
+
? { ok: true, artifactHash: sealed.artifactHash }
|
|
137
|
+
: {
|
|
138
|
+
ok: false,
|
|
139
|
+
kind: "retryable",
|
|
140
|
+
code: sealed.code,
|
|
141
|
+
stage: "candidate-seal",
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/candidate-patch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { compareCanonicalStrings } from "./canonical.ts";
|
|
4
5
|
|
|
5
6
|
import { isValidRelativePath } from "./contracts.ts";
|
|
6
7
|
import { observeSafePath } from "./safe-path.ts";
|
|
@@ -146,7 +147,7 @@ export function compileCandidatePatch(input: {
|
|
|
146
147
|
return state;
|
|
147
148
|
};
|
|
148
149
|
|
|
149
|
-
for (const operation of input.operations) {
|
|
150
|
+
for (const [operationIndex, operation] of input.operations.entries()) {
|
|
150
151
|
if (
|
|
151
152
|
!operation ||
|
|
152
153
|
typeof operation !== "object" ||
|
|
@@ -165,20 +166,40 @@ export function compileCandidatePatch(input: {
|
|
|
165
166
|
throw new Error("candidate-patch-replace-invalid");
|
|
166
167
|
}
|
|
167
168
|
const relative = requirePath(operation.path);
|
|
169
|
+
const reject = (reason: string, hint: string): never => {
|
|
170
|
+
throw new Error(
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
code: "candidate-patch-replace-invalid",
|
|
173
|
+
operationIndex,
|
|
174
|
+
...(relative.length <= 512 && !/[\p{Cc}\p{Cf}]/u.test(relative)
|
|
175
|
+
? { path: relative }
|
|
176
|
+
: {}),
|
|
177
|
+
reason,
|
|
178
|
+
hint,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
};
|
|
168
182
|
if (!writePaths.has(relative)) {
|
|
169
|
-
|
|
183
|
+
reject(
|
|
184
|
+
"path-not-authorized",
|
|
185
|
+
"Use only approved write paths; request context instead of widening authority.",
|
|
186
|
+
);
|
|
170
187
|
}
|
|
171
188
|
const state = load(relative);
|
|
172
|
-
|
|
173
|
-
state.current
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
189
|
+
const current =
|
|
190
|
+
state.current ??
|
|
191
|
+
reject(
|
|
192
|
+
"file-not-found",
|
|
193
|
+
"Use create for a new approved file, or correct the path.",
|
|
194
|
+
);
|
|
195
|
+
const occurrences = occurrenceCount(current, operation.oldText);
|
|
196
|
+
if (occurrences !== 1) {
|
|
197
|
+
reject(
|
|
198
|
+
occurrences === 0 ? "old-text-not-found" : "old-text-ambiguous",
|
|
199
|
+
"Read the approved file and supply exact unique oldText, including enough surrounding context. Operations are applied in order.",
|
|
200
|
+
);
|
|
177
201
|
}
|
|
178
|
-
state.current =
|
|
179
|
-
operation.oldText,
|
|
180
|
-
operation.newText,
|
|
181
|
-
);
|
|
202
|
+
state.current = current.replace(operation.oldText, operation.newText);
|
|
182
203
|
break;
|
|
183
204
|
}
|
|
184
205
|
case "rewrite": {
|
|
@@ -242,7 +263,7 @@ export function compileCandidatePatch(input: {
|
|
|
242
263
|
|
|
243
264
|
const diff = [...states.values()]
|
|
244
265
|
.filter((state) => state.base !== state.current)
|
|
245
|
-
.sort((left, right) => left.path
|
|
266
|
+
.sort((left, right) => compareCanonicalStrings(left.path, right.path))
|
|
246
267
|
.map(renderFileDiff)
|
|
247
268
|
.join("");
|
|
248
269
|
if (diff.length === 0) throw new Error("candidate-patch-empty");
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { observeSafePath } from "./safe-path.ts";
|
|
5
|
+
|
|
6
|
+
export function runGitApply(
|
|
7
|
+
root: string,
|
|
8
|
+
diff: Uint8Array,
|
|
9
|
+
checkOnly: boolean,
|
|
10
|
+
signal: AbortSignal,
|
|
11
|
+
): Promise<"ok" | "failed" | "cancelled"> {
|
|
12
|
+
if (signal.aborted) return Promise.resolve("cancelled");
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const settle = (value: "ok" | "failed" | "cancelled") => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
signal.removeEventListener("abort", abort);
|
|
19
|
+
resolve(value);
|
|
20
|
+
};
|
|
21
|
+
const child = spawn(
|
|
22
|
+
"git",
|
|
23
|
+
[
|
|
24
|
+
"apply",
|
|
25
|
+
...(checkOnly ? ["--check"] : []),
|
|
26
|
+
"--recount",
|
|
27
|
+
"--whitespace=nowarn",
|
|
28
|
+
"-",
|
|
29
|
+
],
|
|
30
|
+
{ cwd: root, shell: false, stdio: ["pipe", "ignore", "ignore"] },
|
|
31
|
+
);
|
|
32
|
+
const abort = () => {
|
|
33
|
+
child.kill("SIGTERM");
|
|
34
|
+
settle("cancelled");
|
|
35
|
+
};
|
|
36
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
37
|
+
child.once("error", () => settle(signal.aborted ? "cancelled" : "failed"));
|
|
38
|
+
child.once("close", (code) =>
|
|
39
|
+
settle(signal.aborted ? "cancelled" : code === 0 ? "ok" : "failed"),
|
|
40
|
+
);
|
|
41
|
+
child.stdin.on("error", () => undefined);
|
|
42
|
+
child.stdin.end(Buffer.from(diff));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function revisionChanges(
|
|
46
|
+
root: string,
|
|
47
|
+
paths: readonly string[],
|
|
48
|
+
): Record<
|
|
49
|
+
string,
|
|
50
|
+
{ kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
|
|
51
|
+
> {
|
|
52
|
+
const changes: Record<
|
|
53
|
+
string,
|
|
54
|
+
{ kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
|
|
55
|
+
> = {};
|
|
56
|
+
for (const relative of [...new Set(paths)].sort()) {
|
|
57
|
+
const observation = observeSafePath(root, relative);
|
|
58
|
+
if (observation.kind === "absent") {
|
|
59
|
+
changes[relative] = { kind: "absent" };
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (observation.kind !== "file") {
|
|
63
|
+
throw new Error("workflow-candidate-path-unsafe");
|
|
64
|
+
}
|
|
65
|
+
const target = path.join(root, ...relative.split("/"));
|
|
66
|
+
const stat = lstatSync(target);
|
|
67
|
+
changes[relative] = {
|
|
68
|
+
kind: "file",
|
|
69
|
+
bytes: readFileSync(target),
|
|
70
|
+
mode: stat.mode & 0o777,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return changes;
|
|
74
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Canonical identity ordering is UTF-16 code-unit order, never host collation. */
|
|
2
|
+
export function compareCanonicalStrings(left: string, right: string): number {
|
|
3
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function canonicalJson(value: unknown): string {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
8
|
+
if (value && typeof value === "object") {
|
|
9
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
10
|
+
.filter(([, entry]) => entry !== undefined)
|
|
11
|
+
.sort(([left], [right]) => compareCanonicalStrings(left, right))
|
|
12
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
13
|
+
.join(",")}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(value) ?? "null";
|
|
16
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import {
|
|
4
|
+
isValidRelativePath,
|
|
5
|
+
VERIFICATION_LIMITS,
|
|
6
|
+
validateVerificationContract,
|
|
7
|
+
} from "./contracts.ts";
|
|
8
|
+
import type { DesignPlanDiagnostic } from "./design-diagnostics.ts";
|
|
9
|
+
|
|
10
|
+
const text = Type.String({ minLength: 1, maxLength: 8192, pattern: "\\S" });
|
|
11
|
+
const id = Type.String({ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$" });
|
|
12
|
+
const object = <T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
|
|
13
|
+
Type.Object(properties, { additionalProperties: false });
|
|
14
|
+
const managers = Type.Union([
|
|
15
|
+
Type.Literal("bun"),
|
|
16
|
+
Type.Literal("npm"),
|
|
17
|
+
Type.Literal("pnpm"),
|
|
18
|
+
Type.Literal("yarn"),
|
|
19
|
+
]);
|
|
20
|
+
const packageRunner = {
|
|
21
|
+
kind: Type.Literal("package-script"),
|
|
22
|
+
packageManager: managers,
|
|
23
|
+
script: text,
|
|
24
|
+
command: text,
|
|
25
|
+
};
|
|
26
|
+
const executable = Type.Union([
|
|
27
|
+
object(packageRunner),
|
|
28
|
+
object({ kind: Type.Literal("local-binary"), executable: text }),
|
|
29
|
+
object({
|
|
30
|
+
kind: Type.Literal("npx"),
|
|
31
|
+
executable: text,
|
|
32
|
+
noInstall: Type.Literal(true),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
const classification = Type.Union([
|
|
36
|
+
Type.Literal("expected-red"),
|
|
37
|
+
Type.Literal("expected-green"),
|
|
38
|
+
Type.Literal("expected-refactor"),
|
|
39
|
+
]);
|
|
40
|
+
const base = {
|
|
41
|
+
id,
|
|
42
|
+
classification,
|
|
43
|
+
expectedFailure: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
|
|
44
|
+
executionBindings: Type.Optional(
|
|
45
|
+
Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
const args = Type.Array(Type.String(), { maxItems: 128 });
|
|
49
|
+
export const ATOMIC_VERIFICATION_SCHEMAS = {
|
|
50
|
+
vitest: object({
|
|
51
|
+
...base,
|
|
52
|
+
kind: Type.Literal("vitest"),
|
|
53
|
+
runner: executable,
|
|
54
|
+
testFiles: Type.Array(text, {
|
|
55
|
+
minItems: VERIFICATION_LIMITS.minTestFiles,
|
|
56
|
+
maxItems: VERIFICATION_LIMITS.maxTestFiles,
|
|
57
|
+
uniqueItems: true,
|
|
58
|
+
}),
|
|
59
|
+
args,
|
|
60
|
+
minTests: Type.Integer({ minimum: 1 }),
|
|
61
|
+
}),
|
|
62
|
+
"package-script": object({ ...base, ...packageRunner, args }),
|
|
63
|
+
"static-check": object({
|
|
64
|
+
...base,
|
|
65
|
+
kind: Type.Literal("static-check"),
|
|
66
|
+
runner: Type.Union([
|
|
67
|
+
executable,
|
|
68
|
+
object({ kind: Type.Literal("node"), script: text }),
|
|
69
|
+
]),
|
|
70
|
+
args,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
const atomic = Type.Union(Object.values(ATOMIC_VERIFICATION_SCHEMAS));
|
|
74
|
+
const steps = object({
|
|
75
|
+
kind: Type.Literal("steps"),
|
|
76
|
+
id,
|
|
77
|
+
classification,
|
|
78
|
+
steps: Type.Array(atomic, {
|
|
79
|
+
minItems: VERIFICATION_LIMITS.minSteps,
|
|
80
|
+
maxItems: VERIFICATION_LIMITS.maxSteps,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
export const VERIFICATION_SCHEMA = Type.Union([atomic, steps]);
|
|
84
|
+
export const CHANGE_CONTRACT_SCHEMA = object({
|
|
85
|
+
goal: text,
|
|
86
|
+
acceptance: Type.Array(
|
|
87
|
+
object({ id, statement: text, verification: VERIFICATION_SCHEMA }),
|
|
88
|
+
{ minItems: 1, maxItems: 256 },
|
|
89
|
+
),
|
|
90
|
+
constraints: Type.Array(object({ id, statement: text }), { maxItems: 256 }),
|
|
91
|
+
policy: object({
|
|
92
|
+
writeRoots: Type.Array(text, {
|
|
93
|
+
minItems: 1,
|
|
94
|
+
maxItems: 256,
|
|
95
|
+
uniqueItems: true,
|
|
96
|
+
}),
|
|
97
|
+
dependencies: Type.Array(text, { maxItems: 256, uniqueItems: true }),
|
|
98
|
+
verificationModes: Type.Array(
|
|
99
|
+
Type.Union([
|
|
100
|
+
Type.Literal("behavior"),
|
|
101
|
+
Type.Literal("mechanical"),
|
|
102
|
+
Type.Literal("refactor"),
|
|
103
|
+
]),
|
|
104
|
+
{ minItems: 1, maxItems: 256, uniqueItems: true },
|
|
105
|
+
),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const schemaFields = new Set([
|
|
113
|
+
"goal",
|
|
114
|
+
"acceptance",
|
|
115
|
+
"id",
|
|
116
|
+
"statement",
|
|
117
|
+
"verification",
|
|
118
|
+
"constraints",
|
|
119
|
+
"policy",
|
|
120
|
+
"writeRoots",
|
|
121
|
+
"dependencies",
|
|
122
|
+
"verificationModes",
|
|
123
|
+
"classification",
|
|
124
|
+
"expectedFailure",
|
|
125
|
+
"executionBindings",
|
|
126
|
+
"kind",
|
|
127
|
+
"runner",
|
|
128
|
+
"packageManager",
|
|
129
|
+
"script",
|
|
130
|
+
"command",
|
|
131
|
+
"executable",
|
|
132
|
+
"noInstall",
|
|
133
|
+
"testFiles",
|
|
134
|
+
"args",
|
|
135
|
+
"minTests",
|
|
136
|
+
"steps",
|
|
137
|
+
]);
|
|
138
|
+
function diagnosticField(pointer: string): string {
|
|
139
|
+
const fields = ["contract"];
|
|
140
|
+
for (const part of pointer.split("/").slice(1)) {
|
|
141
|
+
if (!schemaFields.has(part) && !/^(0|[1-9][0-9]{0,5})$/u.test(part)) break;
|
|
142
|
+
fields.push(part);
|
|
143
|
+
// Binding keys are author data, even when they happen to look like schema fields.
|
|
144
|
+
if (part === "executionBindings") break;
|
|
145
|
+
}
|
|
146
|
+
return fields.join(".");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Shared shape, bounded field-only diagnostics; never return submitted values or parser messages. */
|
|
150
|
+
export function changeContractDiagnostics(
|
|
151
|
+
value: unknown,
|
|
152
|
+
): DesignPlanDiagnostic[] {
|
|
153
|
+
const diagnostics: DesignPlanDiagnostic[] = Value.Errors(
|
|
154
|
+
CHANGE_CONTRACT_SCHEMA,
|
|
155
|
+
value,
|
|
156
|
+
)
|
|
157
|
+
.slice(0, 32)
|
|
158
|
+
.map((error) => ({
|
|
159
|
+
code: "change-contract-field-invalid",
|
|
160
|
+
field: diagnosticField(error.instancePath),
|
|
161
|
+
category: error.keyword,
|
|
162
|
+
}));
|
|
163
|
+
if (!record(value)) return diagnostics;
|
|
164
|
+
if (Array.isArray(value.acceptance))
|
|
165
|
+
value.acceptance.slice(0, 256).forEach((item, index) => {
|
|
166
|
+
if (!record(item)) return;
|
|
167
|
+
const verification = item.verification;
|
|
168
|
+
if (!validateVerificationContract(verification).ok) {
|
|
169
|
+
const field = `contract.acceptance.${index}.verification`;
|
|
170
|
+
if (
|
|
171
|
+
record(verification) &&
|
|
172
|
+
verification.classification === "expected-red" &&
|
|
173
|
+
typeof verification.expectedFailure !== "string"
|
|
174
|
+
)
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
code: "change-contract-field-invalid",
|
|
177
|
+
field: `${field}.expectedFailure`,
|
|
178
|
+
});
|
|
179
|
+
else diagnostics.push({ code: "change-contract-field-invalid", field });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (record(value.policy) && Array.isArray(value.policy.writeRoots))
|
|
183
|
+
value.policy.writeRoots.slice(0, 256).forEach((root, index) => {
|
|
184
|
+
if (root !== "." && !isValidRelativePath(root))
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
code: "change-contract-field-invalid",
|
|
187
|
+
field: `contract.policy.writeRoots.${index}`,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
return diagnostics.slice(0, 32);
|
|
191
|
+
}
|