@agentskit/doc-bridge 1.4.3 → 1.5.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/CHANGELOG.md +6 -0
- package/action.yml +1 -1
- package/dist/cli/program.js +2412 -268
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +74 -3
- package/dist/config/index.js.map +1 -1
- package/dist/{index-DhoAG9Ar.d.ts → index-Di7PkJuf.d.ts} +195 -8
- package/dist/index.d.ts +1942 -4
- package/dist/index.js +2143 -189
- package/dist/index.js.map +1 -1
- package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
- package/docs/knowledge-engine-runbook.md +44 -0
- package/ecosystem-claims.json +16 -16
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +84 -127
- package/mcpb/manifest.json +25 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +97 -0
- package/src/cli/program.ts +285 -3
- package/src/config/defaults.ts +14 -1
- package/src/config/schema.ts +67 -0
- package/src/discovery/documentation.ts +320 -0
- package/src/discovery/repository.ts +514 -0
- package/src/fixes/proposals.ts +165 -0
- package/src/index-builder/content-hash.ts +9 -2
- package/src/index.ts +99 -2
- package/src/mcp/server.ts +178 -8
- package/src/reconciliation/reconcile.ts +227 -0
- package/src/report/html.ts +74 -0
- package/src/rules/engine.ts +180 -0
- package/src/safety/repository.ts +84 -0
- package/src/schemas/knowledge.ts +315 -0
- package/src/validate.ts +24 -0
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +238 -0
- package/tsup.config.ts +2 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const KNOWLEDGE_SCHEMA_VERSION = 1 as const
|
|
4
|
+
export const KNOWLEDGE_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
|
|
5
|
+
|
|
6
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
7
|
+
const boundedString = (max: number) => z.string().min(1).max(max)
|
|
8
|
+
|
|
9
|
+
export const ProvenanceSchema = z.enum(['observed', 'declared', 'proposed'])
|
|
10
|
+
export type Provenance = z.infer<typeof ProvenanceSchema>
|
|
11
|
+
|
|
12
|
+
export const FindingStatusSchema = z.enum([
|
|
13
|
+
'confirmed',
|
|
14
|
+
'undocumented',
|
|
15
|
+
'stale-or-unverified',
|
|
16
|
+
'conflict',
|
|
17
|
+
'unresolved',
|
|
18
|
+
'not-analyzed',
|
|
19
|
+
])
|
|
20
|
+
export type FindingStatus = z.infer<typeof FindingStatusSchema>
|
|
21
|
+
|
|
22
|
+
export const DiagnosticSeveritySchema = z.enum(['off', 'info', 'warn', 'error'])
|
|
23
|
+
export type DiagnosticSeverity = z.infer<typeof DiagnosticSeveritySchema>
|
|
24
|
+
|
|
25
|
+
export const EvidenceSourceSchema = z.enum([
|
|
26
|
+
'code',
|
|
27
|
+
'configuration',
|
|
28
|
+
'documentation',
|
|
29
|
+
'agent',
|
|
30
|
+
'derived',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
export const EvidenceSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
source: EvidenceSourceSchema,
|
|
36
|
+
path: boundedString(512),
|
|
37
|
+
lineStart: z.number().int().positive().optional(),
|
|
38
|
+
lineEnd: z.number().int().positive().optional(),
|
|
39
|
+
contentHash: hash.optional(),
|
|
40
|
+
context: z.string().max(1_024).optional(),
|
|
41
|
+
})
|
|
42
|
+
.strict()
|
|
43
|
+
.superRefine((value, context) => {
|
|
44
|
+
if (value.lineStart !== undefined && value.lineEnd !== undefined && value.lineEnd < value.lineStart) {
|
|
45
|
+
context.addIssue({
|
|
46
|
+
code: z.ZodIssueCode.custom,
|
|
47
|
+
path: ['lineEnd'],
|
|
48
|
+
message: 'Must be greater than or equal to lineStart',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
export type Evidence = z.infer<typeof EvidenceSchema>
|
|
53
|
+
|
|
54
|
+
export const CoverageStatusSchema = z.enum(['complete', 'partial', 'not-analyzed'])
|
|
55
|
+
|
|
56
|
+
export const CoverageSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
analyzer: boundedString(128),
|
|
59
|
+
scope: boundedString(512),
|
|
60
|
+
status: CoverageStatusSchema,
|
|
61
|
+
reason: z.string().max(1_024).optional(),
|
|
62
|
+
evidence: z.array(EvidenceSchema).max(32).optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
export type Coverage = z.infer<typeof CoverageSchema>
|
|
66
|
+
|
|
67
|
+
export const ProjectIdentitySchema = z
|
|
68
|
+
.object({
|
|
69
|
+
name: boundedString(128),
|
|
70
|
+
root: boundedString(512).optional(),
|
|
71
|
+
})
|
|
72
|
+
.strict()
|
|
73
|
+
|
|
74
|
+
const ArtifactMetadata = {
|
|
75
|
+
schemaVersion: z.literal(KNOWLEDGE_SCHEMA_VERSION),
|
|
76
|
+
contentHash: hash,
|
|
77
|
+
contentHashAlgo: z.literal(KNOWLEDGE_CONTENT_HASH_ALGO),
|
|
78
|
+
project: ProjectIdentitySchema,
|
|
79
|
+
sourceRevision: boundedString(128),
|
|
80
|
+
sourceRevisionKind: z.enum(['git', 'content']),
|
|
81
|
+
configurationHash: hash,
|
|
82
|
+
pipelineVersion: boundedString(64),
|
|
83
|
+
analyzerVersions: z.record(boundedString(128), boundedString(64)),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const EntitySchema = z
|
|
87
|
+
.object({
|
|
88
|
+
id: boundedString(256),
|
|
89
|
+
kind: boundedString(128),
|
|
90
|
+
name: boundedString(256),
|
|
91
|
+
path: boundedString(512).optional(),
|
|
92
|
+
aliases: z.array(boundedString(256)).max(32).optional(),
|
|
93
|
+
provenance: ProvenanceSchema,
|
|
94
|
+
evidence: z.array(EvidenceSchema).max(64),
|
|
95
|
+
metadata: z.record(z.unknown()).optional(),
|
|
96
|
+
})
|
|
97
|
+
.strict()
|
|
98
|
+
export type KnowledgeEntity = z.infer<typeof EntitySchema>
|
|
99
|
+
|
|
100
|
+
export const RelationSchema = z
|
|
101
|
+
.object({
|
|
102
|
+
id: boundedString(256),
|
|
103
|
+
kind: boundedString(128),
|
|
104
|
+
from: boundedString(256),
|
|
105
|
+
to: boundedString(256),
|
|
106
|
+
discriminator: boundedString(256).optional(),
|
|
107
|
+
provenance: ProvenanceSchema,
|
|
108
|
+
evidence: z.array(EvidenceSchema).max(64),
|
|
109
|
+
metadata: z.record(z.unknown()).optional(),
|
|
110
|
+
})
|
|
111
|
+
.strict()
|
|
112
|
+
export type KnowledgeRelation = z.infer<typeof RelationSchema>
|
|
113
|
+
|
|
114
|
+
export const DiscoverySnapshotV1Schema = z
|
|
115
|
+
.object({
|
|
116
|
+
type: z.literal('discovery-snapshot'),
|
|
117
|
+
...ArtifactMetadata,
|
|
118
|
+
entities: z.array(EntitySchema).max(50_000),
|
|
119
|
+
relations: z.array(RelationSchema).max(100_000),
|
|
120
|
+
coverage: z.array(CoverageSchema).max(1_000),
|
|
121
|
+
})
|
|
122
|
+
.strict()
|
|
123
|
+
.superRefine((value, context) => {
|
|
124
|
+
const entityIds = new Set<string>()
|
|
125
|
+
for (const [index, entity] of value.entities.entries()) {
|
|
126
|
+
if (entityIds.has(entity.id)) {
|
|
127
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['entities', index, 'id'], message: `Duplicate entity id: ${entity.id}` })
|
|
128
|
+
}
|
|
129
|
+
entityIds.add(entity.id)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const relationIds = new Set<string>()
|
|
133
|
+
for (const [index, relation] of value.relations.entries()) {
|
|
134
|
+
if (relationIds.has(relation.id)) {
|
|
135
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['relations', index, 'id'], message: `Duplicate relation id: ${relation.id}` })
|
|
136
|
+
}
|
|
137
|
+
relationIds.add(relation.id)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
export type DiscoverySnapshotV1 = z.infer<typeof DiscoverySnapshotV1Schema>
|
|
141
|
+
|
|
142
|
+
export const DiagnosticSchema = z
|
|
143
|
+
.object({
|
|
144
|
+
id: boundedString(256),
|
|
145
|
+
code: boundedString(128),
|
|
146
|
+
status: FindingStatusSchema,
|
|
147
|
+
severity: DiagnosticSeveritySchema,
|
|
148
|
+
message: boundedString(2_048),
|
|
149
|
+
evidence: z.array(EvidenceSchema).max(64),
|
|
150
|
+
entityIds: z.array(boundedString(256)).max(64).optional(),
|
|
151
|
+
relationIds: z.array(boundedString(256)).max(64).optional(),
|
|
152
|
+
remediation: z.string().max(2_048).optional(),
|
|
153
|
+
})
|
|
154
|
+
.strict()
|
|
155
|
+
export type KnowledgeDiagnostic = z.infer<typeof DiagnosticSchema>
|
|
156
|
+
|
|
157
|
+
export const ReconciliationReportV1Schema = z
|
|
158
|
+
.object({
|
|
159
|
+
type: z.literal('reconciliation-report'),
|
|
160
|
+
...ArtifactMetadata,
|
|
161
|
+
snapshotHash: hash,
|
|
162
|
+
diagnostics: z.array(DiagnosticSchema).max(100_000),
|
|
163
|
+
summary: z
|
|
164
|
+
.object({
|
|
165
|
+
entityCount: z.number().int().nonnegative(),
|
|
166
|
+
relationCount: z.number().int().nonnegative(),
|
|
167
|
+
diagnosticCount: z.number().int().nonnegative(),
|
|
168
|
+
})
|
|
169
|
+
.strict(),
|
|
170
|
+
})
|
|
171
|
+
.strict()
|
|
172
|
+
export type ReconciliationReportV1 = z.infer<typeof ReconciliationReportV1Schema>
|
|
173
|
+
|
|
174
|
+
export const WorkflowStateSchema = z.enum([
|
|
175
|
+
'created',
|
|
176
|
+
'discovering',
|
|
177
|
+
'analyzed',
|
|
178
|
+
'compared',
|
|
179
|
+
'awaiting-agent',
|
|
180
|
+
'proposed',
|
|
181
|
+
'awaiting-approval',
|
|
182
|
+
'validating',
|
|
183
|
+
'delivered',
|
|
184
|
+
'failed',
|
|
185
|
+
'cancelled',
|
|
186
|
+
'stale',
|
|
187
|
+
'superseded',
|
|
188
|
+
])
|
|
189
|
+
export type WorkflowState = z.infer<typeof WorkflowStateSchema>
|
|
190
|
+
|
|
191
|
+
export const WorkflowStepSchema = z
|
|
192
|
+
.object({
|
|
193
|
+
name: boundedString(128),
|
|
194
|
+
status: z.enum(['pending', 'running', 'completed', 'failed', 'skipped']),
|
|
195
|
+
inputHash: hash,
|
|
196
|
+
outputHash: hash.optional(),
|
|
197
|
+
artifactRefs: z.array(boundedString(512)).max(32).optional(),
|
|
198
|
+
})
|
|
199
|
+
.strict()
|
|
200
|
+
export type WorkflowStep = z.infer<typeof WorkflowStepSchema>
|
|
201
|
+
|
|
202
|
+
export const WorkflowTransitionSchema = z
|
|
203
|
+
.object({
|
|
204
|
+
from: WorkflowStateSchema.nullable(),
|
|
205
|
+
to: WorkflowStateSchema,
|
|
206
|
+
at: z.string().datetime(),
|
|
207
|
+
reason: z.string().max(1_024).optional(),
|
|
208
|
+
})
|
|
209
|
+
.strict()
|
|
210
|
+
export type WorkflowTransition = z.infer<typeof WorkflowTransitionSchema>
|
|
211
|
+
|
|
212
|
+
export const WorkflowRunV1Schema = z
|
|
213
|
+
.object({
|
|
214
|
+
type: z.literal('workflow-run'),
|
|
215
|
+
...ArtifactMetadata,
|
|
216
|
+
runId: boundedString(128),
|
|
217
|
+
state: WorkflowStateSchema,
|
|
218
|
+
steps: z.array(WorkflowStepSchema).max(32),
|
|
219
|
+
transitions: z.array(WorkflowTransitionSchema).max(1_000),
|
|
220
|
+
artifactRefs: z.array(boundedString(512)).max(128),
|
|
221
|
+
})
|
|
222
|
+
.strict()
|
|
223
|
+
export type WorkflowRunV1 = z.infer<typeof WorkflowRunV1Schema>
|
|
224
|
+
|
|
225
|
+
export const ProposalOriginSchema = z
|
|
226
|
+
.object({
|
|
227
|
+
kind: z.enum(['registry-agent', 'manual', 'deterministic']),
|
|
228
|
+
id: boundedString(256).optional(),
|
|
229
|
+
version: boundedString(64).optional(),
|
|
230
|
+
provider: boundedString(128).optional(),
|
|
231
|
+
model: boundedString(256).optional(),
|
|
232
|
+
capabilities: z.array(boundedString(128)).max(32).optional(),
|
|
233
|
+
})
|
|
234
|
+
.strict()
|
|
235
|
+
.superRefine((value, context) => {
|
|
236
|
+
if (value.kind === 'registry-agent' && value.id === undefined) {
|
|
237
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['id'], message: 'Registry agent origin requires an id' })
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
export const AgentProposalV1Schema = z
|
|
242
|
+
.object({
|
|
243
|
+
type: z.literal('agent-proposal'),
|
|
244
|
+
...ArtifactMetadata,
|
|
245
|
+
proposalId: boundedString(128),
|
|
246
|
+
baseSnapshotHash: hash,
|
|
247
|
+
baseReportHash: hash,
|
|
248
|
+
relatedDiagnosticIds: z.array(boundedString(256)).max(64),
|
|
249
|
+
rationale: boundedString(4_000),
|
|
250
|
+
confidence: z.number().min(0).max(1),
|
|
251
|
+
evidence: z.array(EvidenceSchema).max(128),
|
|
252
|
+
intendedChanges: z.array(boundedString(4_000)).max(64),
|
|
253
|
+
origin: ProposalOriginSchema,
|
|
254
|
+
checks: z.array(boundedString(512)).max(32),
|
|
255
|
+
})
|
|
256
|
+
.strict()
|
|
257
|
+
export type AgentProposalV1 = z.infer<typeof AgentProposalV1Schema>
|
|
258
|
+
|
|
259
|
+
export const FixProposalStatusSchema = z.enum(['proposed', 'approved', 'rejected', 'stale', 'applied', 'failed'])
|
|
260
|
+
|
|
261
|
+
export const AffectedFileSchema = z
|
|
262
|
+
.object({
|
|
263
|
+
path: boundedString(512),
|
|
264
|
+
contentHash: hash,
|
|
265
|
+
})
|
|
266
|
+
.strict()
|
|
267
|
+
|
|
268
|
+
export const FixChangeSchema = z
|
|
269
|
+
.object({
|
|
270
|
+
path: boundedString(512),
|
|
271
|
+
before: z.string().max(100_000),
|
|
272
|
+
after: z.string().max(100_000),
|
|
273
|
+
})
|
|
274
|
+
.strict()
|
|
275
|
+
export type FixChange = z.infer<typeof FixChangeSchema>
|
|
276
|
+
|
|
277
|
+
export const FixProposalV1Schema = z
|
|
278
|
+
.object({
|
|
279
|
+
type: z.literal('fix-proposal'),
|
|
280
|
+
...ArtifactMetadata,
|
|
281
|
+
proposalId: boundedString(128),
|
|
282
|
+
baseRevision: boundedString(128),
|
|
283
|
+
affectedFiles: z.array(AffectedFileSchema).max(256),
|
|
284
|
+
changes: z.array(FixChangeSchema).max(256).optional(),
|
|
285
|
+
preconditions: z.array(boundedString(2_048)).max(64),
|
|
286
|
+
diff: boundedString(100_000),
|
|
287
|
+
postconditions: z.array(boundedString(2_048)).max(64),
|
|
288
|
+
approval: z
|
|
289
|
+
.object({
|
|
290
|
+
proposalHash: hash,
|
|
291
|
+
approvedAt: z.string().datetime(),
|
|
292
|
+
approvedBy: boundedString(256),
|
|
293
|
+
})
|
|
294
|
+
.strict()
|
|
295
|
+
.optional(),
|
|
296
|
+
status: FixProposalStatusSchema,
|
|
297
|
+
})
|
|
298
|
+
.strict()
|
|
299
|
+
.superRefine((value, context) => {
|
|
300
|
+
if ((value.status === 'approved' || value.status === 'applied') && value.approval === undefined) {
|
|
301
|
+
context.addIssue({
|
|
302
|
+
code: z.ZodIssueCode.custom,
|
|
303
|
+
path: ['approval'],
|
|
304
|
+
message: `Fix proposal status ${value.status} requires an approval record`,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
export type FixProposalV1 = z.infer<typeof FixProposalV1Schema>
|
|
309
|
+
|
|
310
|
+
export type KnowledgeArtifactV1 =
|
|
311
|
+
| DiscoverySnapshotV1
|
|
312
|
+
| ReconciliationReportV1
|
|
313
|
+
| WorkflowRunV1
|
|
314
|
+
| AgentProposalV1
|
|
315
|
+
| FixProposalV1
|
package/src/validate.ts
CHANGED
|
@@ -14,6 +14,18 @@ import {
|
|
|
14
14
|
MemoryCandidateV1Schema,
|
|
15
15
|
type MemoryCandidateV1,
|
|
16
16
|
} from './schemas/memory-candidate.js'
|
|
17
|
+
import {
|
|
18
|
+
AgentProposalV1Schema,
|
|
19
|
+
DiscoverySnapshotV1Schema,
|
|
20
|
+
FixProposalV1Schema,
|
|
21
|
+
ReconciliationReportV1Schema,
|
|
22
|
+
WorkflowRunV1Schema,
|
|
23
|
+
type AgentProposalV1,
|
|
24
|
+
type DiscoverySnapshotV1,
|
|
25
|
+
type FixProposalV1,
|
|
26
|
+
type ReconciliationReportV1,
|
|
27
|
+
type WorkflowRunV1,
|
|
28
|
+
} from './schemas/knowledge.js'
|
|
17
29
|
|
|
18
30
|
export type ParseIssue = {
|
|
19
31
|
readonly path: string
|
|
@@ -56,6 +68,18 @@ export const parseDocBridgeIndex = (input: unknown): DocBridgeIndexV1 =>
|
|
|
56
68
|
export const parseMemoryCandidate = (input: unknown): MemoryCandidateV1 =>
|
|
57
69
|
MemoryCandidateV1Schema.parse(input)
|
|
58
70
|
|
|
71
|
+
export const parseDiscoverySnapshot = (input: unknown): DiscoverySnapshotV1 =>
|
|
72
|
+
DiscoverySnapshotV1Schema.parse(input)
|
|
73
|
+
|
|
74
|
+
export const parseReconciliationReport = (input: unknown): ReconciliationReportV1 =>
|
|
75
|
+
ReconciliationReportV1Schema.parse(input)
|
|
76
|
+
|
|
77
|
+
export const parseWorkflowRun = (input: unknown): WorkflowRunV1 => WorkflowRunV1Schema.parse(input)
|
|
78
|
+
|
|
79
|
+
export const parseAgentProposal = (input: unknown): AgentProposalV1 => AgentProposalV1Schema.parse(input)
|
|
80
|
+
|
|
81
|
+
export const parseFixProposal = (input: unknown): FixProposalV1 => FixProposalV1Schema.parse(input)
|
|
82
|
+
|
|
59
83
|
export const parseDocBridgeConfig = (input: unknown): DocBridgeConfigV1 => {
|
|
60
84
|
const result = DocBridgeConfigV1Schema.safeParse(input)
|
|
61
85
|
if (!result.success) {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = '1.
|
|
1
|
+
export const PACKAGE_VERSION = '1.5.0'
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
5
|
+
import { WorkflowRunV1Schema, type WorkflowRunV1, type WorkflowState, type WorkflowStep } from '../schemas/knowledge.js'
|
|
6
|
+
|
|
7
|
+
export const WORKFLOW_STAGES = ['collect', 'normalize', 'reconcile', 'evaluate', 'report'] as const
|
|
8
|
+
export type WorkflowStage = (typeof WORKFLOW_STAGES)[number]
|
|
9
|
+
|
|
10
|
+
export type WorkflowStageContext = {
|
|
11
|
+
readonly root: string
|
|
12
|
+
readonly stage: WorkflowStage
|
|
13
|
+
readonly input: unknown
|
|
14
|
+
readonly previousOutput: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type WorkflowStageHandler = (context: WorkflowStageContext) => unknown
|
|
18
|
+
|
|
19
|
+
export type WorkflowOptions = {
|
|
20
|
+
readonly root: string
|
|
21
|
+
readonly stateDir?: string
|
|
22
|
+
readonly sourceRevision: string
|
|
23
|
+
readonly configurationHash: string
|
|
24
|
+
readonly toolVersion?: string
|
|
25
|
+
readonly runId?: string
|
|
26
|
+
readonly stage?: WorkflowStage | 'all'
|
|
27
|
+
readonly inputs?: Partial<Record<WorkflowStage, unknown>>
|
|
28
|
+
readonly handlers: Partial<Record<WorkflowStage, WorkflowStageHandler>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type WorkflowExecutionResult = {
|
|
32
|
+
readonly run: WorkflowRunV1
|
|
33
|
+
readonly stateDir: string
|
|
34
|
+
readonly reusedStages: readonly WorkflowStage[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PersistedArtifact = {
|
|
38
|
+
readonly type: 'workflow-step-artifact'
|
|
39
|
+
readonly stage: WorkflowStage
|
|
40
|
+
readonly inputHash: string
|
|
41
|
+
readonly outputHash: string
|
|
42
|
+
readonly value: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stageState: Record<WorkflowStage, WorkflowState> = {
|
|
46
|
+
collect: 'discovering',
|
|
47
|
+
normalize: 'analyzed',
|
|
48
|
+
reconcile: 'compared',
|
|
49
|
+
evaluate: 'proposed',
|
|
50
|
+
report: 'delivered',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const defaultStateDir = (root: string): string => join(root, '.doc-bridge', 'workflow')
|
|
54
|
+
|
|
55
|
+
const atomicWrite = (path: string, value: unknown): void => {
|
|
56
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
57
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
58
|
+
renameSync(temp, path)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const writeManifest = (stateDir: string, run: WorkflowRunV1): void => {
|
|
62
|
+
atomicWrite(join(stateDir, 'manifest.json'), run)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const appendTransition = (stateDir: string, transition: WorkflowRunV1['transitions'][number]): void => {
|
|
66
|
+
appendFileSync(join(stateDir, 'transitions.jsonl'), `${JSON.stringify(transition)}\n`, 'utf8')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const transition = (run: WorkflowRunV1, to: WorkflowState, reason?: string): WorkflowRunV1 => {
|
|
70
|
+
const item = {
|
|
71
|
+
from: run.state,
|
|
72
|
+
to,
|
|
73
|
+
at: new Date().toISOString(),
|
|
74
|
+
...(reason ? { reason } : {}),
|
|
75
|
+
}
|
|
76
|
+
return { ...run, state: to, transitions: [...run.transitions, item] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const runId = (): string => `${Date.now()}-${process.pid}`
|
|
80
|
+
|
|
81
|
+
const stageInputHash = (options: WorkflowOptions, stage: WorkflowStage, input: unknown): string =>
|
|
82
|
+
sha256NormalizedV1({ stage, input, sourceRevision: options.sourceRevision, configurationHash: options.configurationHash, toolVersion: options.toolVersion ?? '1.0.0' })
|
|
83
|
+
|
|
84
|
+
const stageArtifactPath = (stateDir: string, stage: WorkflowStage, inputHash: string): string => join(stateDir, 'artifacts', `${stage}-${inputHash}.json`)
|
|
85
|
+
|
|
86
|
+
const readArtifact = (path: string): PersistedArtifact => JSON.parse(readFileSync(path, 'utf8')) as PersistedArtifact
|
|
87
|
+
|
|
88
|
+
const stepArtifactPath = (stateDir: string, step: WorkflowStep): string => resolve(stateDir, step.artifactRefs?.[0] ?? '')
|
|
89
|
+
|
|
90
|
+
const stepOutput = (stateDir: string, run: WorkflowRunV1, stage: WorkflowStage): unknown => {
|
|
91
|
+
const step = run.steps.find((item) => item.name === stage)
|
|
92
|
+
if (!step || step.status !== 'completed' || !step.artifactRefs?.[0]) return null
|
|
93
|
+
return readArtifact(stepArtifactPath(stateDir, step)).value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const acquireLock = (stateDir: string): (() => void) => {
|
|
97
|
+
const lock = join(stateDir, '.lock')
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(lock)
|
|
100
|
+
} catch {
|
|
101
|
+
const ownerPath = join(lock, 'owner.json')
|
|
102
|
+
try {
|
|
103
|
+
const owner = JSON.parse(readFileSync(ownerPath, 'utf8')) as { pid?: number }
|
|
104
|
+
if (typeof owner.pid === 'number') process.kill(owner.pid, 0)
|
|
105
|
+
throw new Error(`Workflow is already running (pid ${owner.pid ?? 'unknown'}).`)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof Error && error.message.startsWith('Workflow is already running')) throw error
|
|
108
|
+
rmSync(lock, { recursive: true, force: true })
|
|
109
|
+
mkdirSync(lock)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), 'utf8')
|
|
113
|
+
return () => rmSync(lock, { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const loadManifest = (stateDir: string): WorkflowRunV1 | undefined => {
|
|
117
|
+
const path = join(stateDir, 'manifest.json')
|
|
118
|
+
if (!existsSync(path)) return undefined
|
|
119
|
+
return WorkflowRunV1Schema.parse(JSON.parse(readFileSync(path, 'utf8')) as unknown)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const baseRun = (options: WorkflowOptions, stateDir: string, supersedes?: string): WorkflowRunV1 => {
|
|
123
|
+
const inputHash = sha256NormalizedV1({ sourceRevision: options.sourceRevision, configurationHash: options.configurationHash, toolVersion: options.toolVersion ?? '1.0.0' })
|
|
124
|
+
return WorkflowRunV1Schema.parse({
|
|
125
|
+
type: 'workflow-run',
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
contentHash: '0'.repeat(64),
|
|
128
|
+
contentHashAlgo: 'sha256-normalized-v1',
|
|
129
|
+
project: { name: resolve(options.root).split('/').pop() ?? 'project', root: '.' },
|
|
130
|
+
sourceRevision: options.sourceRevision,
|
|
131
|
+
sourceRevisionKind: 'content',
|
|
132
|
+
configurationHash: options.configurationHash,
|
|
133
|
+
pipelineVersion: '1.0.0',
|
|
134
|
+
analyzerVersions: { workflow: options.toolVersion ?? '1.0.0' },
|
|
135
|
+
runId: options.runId ?? runId(),
|
|
136
|
+
state: 'created',
|
|
137
|
+
steps: WORKFLOW_STAGES.map((name) => ({ name, status: 'pending', inputHash })) as WorkflowStep[],
|
|
138
|
+
transitions: [{ from: null, to: 'created', at: new Date().toISOString() }],
|
|
139
|
+
artifactRefs: [relative(resolve(options.root), stateDir), ...(supersedes ? [`supersedes:${supersedes}`] : [])],
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const withHash = (run: WorkflowRunV1): WorkflowRunV1 => WorkflowRunV1Schema.parse({ ...run, contentHash: contentHashForArtifactV1(run) })
|
|
144
|
+
|
|
145
|
+
const sameInputs = (run: WorkflowRunV1, options: WorkflowOptions): boolean =>
|
|
146
|
+
run.sourceRevision === options.sourceRevision &&
|
|
147
|
+
run.configurationHash === options.configurationHash &&
|
|
148
|
+
run.analyzerVersions.workflow === (options.toolVersion ?? '1.0.0')
|
|
149
|
+
|
|
150
|
+
const selectedStages = (stage: WorkflowOptions['stage']): readonly WorkflowStage[] =>
|
|
151
|
+
stage && stage !== 'all' ? [stage] : WORKFLOW_STAGES
|
|
152
|
+
|
|
153
|
+
export const runWorkflow = (options: WorkflowOptions): WorkflowExecutionResult => {
|
|
154
|
+
const root = resolve(options.root)
|
|
155
|
+
const stateDir = resolve(root, options.stateDir ?? defaultStateDir(root))
|
|
156
|
+
mkdirSync(join(stateDir, 'artifacts'), { recursive: true })
|
|
157
|
+
const release = acquireLock(stateDir)
|
|
158
|
+
try {
|
|
159
|
+
let run = loadManifest(stateDir)
|
|
160
|
+
let supersedes: string | undefined
|
|
161
|
+
if (run && !sameInputs(run, options)) {
|
|
162
|
+
supersedes = run.runId
|
|
163
|
+
run = withHash(transition(run, 'stale', 'Source revision, configuration hash, or tool version changed.'))
|
|
164
|
+
appendTransition(stateDir, run.transitions[run.transitions.length - 1]!)
|
|
165
|
+
writeManifest(stateDir, run)
|
|
166
|
+
run = undefined
|
|
167
|
+
}
|
|
168
|
+
if (!run) {
|
|
169
|
+
run = withHash(baseRun(options, stateDir, supersedes))
|
|
170
|
+
appendTransition(stateDir, run.transitions[0]!)
|
|
171
|
+
writeManifest(stateDir, run)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!run) throw new Error('Workflow manifest was not initialized.')
|
|
175
|
+
const firstSelectedStage = selectedStages(options.stage)[0]
|
|
176
|
+
const previousStageIndex = firstSelectedStage ? WORKFLOW_STAGES.indexOf(firstSelectedStage) - 1 : -1
|
|
177
|
+
let previousOutput: unknown = previousStageIndex >= 0 ? stepOutput(stateDir, run, WORKFLOW_STAGES[previousStageIndex]!) : null
|
|
178
|
+
const reusedStages: WorkflowStage[] = []
|
|
179
|
+
for (const stage of selectedStages(options.stage)) {
|
|
180
|
+
const input = options.inputs?.[stage] ?? previousOutput
|
|
181
|
+
const inputHash = stageInputHash(options, stage, input)
|
|
182
|
+
const existing = run.steps.find((step) => step.name === stage)
|
|
183
|
+
const artifactPath = existing?.artifactRefs?.[0] ? resolve(stateDir, existing.artifactRefs[0]) : stageArtifactPath(stateDir, stage, inputHash)
|
|
184
|
+
if (existing?.status === 'completed' && existing.inputHash === inputHash && existing.outputHash && existsSync(artifactPath)) {
|
|
185
|
+
previousOutput = readArtifact(artifactPath).value
|
|
186
|
+
reusedStages.push(stage)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const handler = options.handlers[stage]
|
|
191
|
+
if (!handler) throw new Error(`No handler configured for workflow stage "${stage}".`)
|
|
192
|
+
run = withHash(transition(run, stageState[stage]))
|
|
193
|
+
appendTransition(stateDir, run.transitions[run.transitions.length - 1]!)
|
|
194
|
+
const runningStep: WorkflowStep = { name: stage, status: 'running', inputHash }
|
|
195
|
+
run = withHash({ ...run, steps: run.steps.map((step) => step.name === stage ? runningStep : step) })
|
|
196
|
+
writeManifest(stateDir, run)
|
|
197
|
+
try {
|
|
198
|
+
const value = handler({ root, stage, input, previousOutput })
|
|
199
|
+
const outputHash = sha256NormalizedV1(value)
|
|
200
|
+
const artifact: PersistedArtifact = { type: 'workflow-step-artifact', stage, inputHash, outputHash, value }
|
|
201
|
+
mkdirSync(join(stateDir, 'artifacts'), { recursive: true })
|
|
202
|
+
if (existsSync(artifactPath)) {
|
|
203
|
+
const existingArtifact = readArtifact(artifactPath)
|
|
204
|
+
if (existingArtifact.outputHash !== outputHash) throw new Error(`Immutable workflow artifact collision for stage "${stage}".`)
|
|
205
|
+
} else {
|
|
206
|
+
atomicWrite(artifactPath, artifact)
|
|
207
|
+
}
|
|
208
|
+
const ref = relative(stateDir, artifactPath)
|
|
209
|
+
const completedStep: WorkflowStep = { name: stage, status: 'completed', inputHash, outputHash, artifactRefs: [ref] }
|
|
210
|
+
run = withHash({ ...run, steps: run.steps.map((step) => step.name === stage ? completedStep : step) })
|
|
211
|
+
writeManifest(stateDir, run)
|
|
212
|
+
previousOutput = value
|
|
213
|
+
} catch (error) {
|
|
214
|
+
run = withHash(transition(run, 'failed', error instanceof Error ? error.message : String(error)))
|
|
215
|
+
appendTransition(stateDir, run.transitions[run.transitions.length - 1]!)
|
|
216
|
+
writeManifest(stateDir, run)
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (selectedStages(options.stage).every((stage) => run!.steps.find((step) => step.name === stage)?.status === 'completed')) {
|
|
222
|
+
const complete = selectedStages(options.stage).includes('report') && run.state !== 'delivered' ? withHash(transition(run, 'delivered')) : run
|
|
223
|
+
if (complete !== run) {
|
|
224
|
+
appendTransition(stateDir, complete.transitions[complete.transitions.length - 1]!)
|
|
225
|
+
run = complete
|
|
226
|
+
}
|
|
227
|
+
writeManifest(stateDir, run)
|
|
228
|
+
if (run.state === 'delivered') atomicWrite(join(stateDir, 'last-known-good.json'), { runId: run.runId, manifestHash: run.contentHash, report: run.steps.find((step) => step.name === 'report')?.artifactRefs?.[0] })
|
|
229
|
+
}
|
|
230
|
+
return { run, stateDir, reusedStages }
|
|
231
|
+
} finally {
|
|
232
|
+
release()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const loadWorkflowManifest = (stateDir: string): WorkflowRunV1 => WorkflowRunV1Schema.parse(JSON.parse(readFileSync(join(resolve(stateDir), 'manifest.json'), 'utf8')) as unknown)
|
|
237
|
+
|
|
238
|
+
export const loadWorkflowStepOutput = (stateDir: string, stage: WorkflowStage): unknown => stepOutput(resolve(stateDir), loadWorkflowManifest(stateDir), stage)
|
package/tsup.config.ts
CHANGED