@llm4ts/flow 0.13.4 → 0.14.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/dist/AzureDevOpsTool.d.ts +16 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -1
- package/dist/AzureDevOpsTool.js +73 -0
- package/dist/AzureDevOpsTool.js.map +1 -1
- package/dist/BoardSync.d.ts +81 -0
- package/dist/BoardSync.d.ts.map +1 -0
- package/dist/BoardSync.js +259 -0
- package/dist/BoardSync.js.map +1 -0
- package/dist/EstimatedUsage.d.ts +28 -0
- package/dist/EstimatedUsage.d.ts.map +1 -0
- package/dist/EstimatedUsage.js +91 -0
- package/dist/EstimatedUsage.js.map +1 -0
- package/dist/Pack.d.ts +1 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +7 -0
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +104 -0
- package/dist/PageSpec.d.ts.map +1 -0
- package/dist/PageSpec.js +272 -0
- package/dist/PageSpec.js.map +1 -0
- package/dist/SpecChecks.d.ts +6 -1
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +11 -4
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Survey.d.ts +38 -1
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +101 -5
- package/dist/Survey.js.map +1 -1
- package/dist/Workspace.d.ts +44 -5
- package/dist/Workspace.d.ts.map +1 -1
- package/dist/Workspace.js +84 -14
- package/dist/Workspace.js.map +1 -1
- package/package.json +5 -2
- package/src/AzureDevOpsTool.ts +115 -0
- package/src/BoardSync.ts +358 -0
- package/src/EstimatedUsage.ts +180 -0
- package/src/Pack.ts +11 -0
- package/src/PageSpec.ts +354 -0
- package/src/SpecChecks.ts +12 -6
- package/src/Survey.ts +137 -5
- package/src/Workspace.ts +120 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Effect-native LLM workflow, persistence, review, and repository automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./BasecampTool": "./dist/BasecampTool.js",
|
|
20
20
|
"./Bench": "./dist/Bench.js",
|
|
21
21
|
"./BenchReport": "./dist/BenchReport.js",
|
|
22
|
+
"./BoardSync": "./dist/BoardSync.js",
|
|
22
23
|
"./CapabilityGuard": "./dist/CapabilityGuard.js",
|
|
23
24
|
"./Chat": "./dist/Chat.js",
|
|
24
25
|
"./Classified": "./dist/Classified.js",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"./CostTracker": "./dist/CostTracker.js",
|
|
28
29
|
"./Equiv": "./dist/Equiv.js",
|
|
29
30
|
"./EquivReport": "./dist/EquivReport.js",
|
|
31
|
+
"./EstimatedUsage": "./dist/EstimatedUsage.js",
|
|
30
32
|
"./Flow": "./dist/Flow.js",
|
|
31
33
|
"./FlowContext": "./dist/FlowContext.js",
|
|
32
34
|
"./FlowError": "./dist/FlowError.js",
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"./Mermaid": "./dist/Mermaid.js",
|
|
40
42
|
"./Pack": "./dist/Pack.js",
|
|
41
43
|
"./Package": "./dist/Package.js",
|
|
44
|
+
"./PageSpec": "./dist/PageSpec.js",
|
|
42
45
|
"./Patterns": "./dist/Patterns.js",
|
|
43
46
|
"./Persistence": "./dist/Persistence.js",
|
|
44
47
|
"./Plan": "./dist/Plan.js",
|
|
@@ -83,7 +86,7 @@
|
|
|
83
86
|
"typescript"
|
|
84
87
|
],
|
|
85
88
|
"dependencies": {
|
|
86
|
-
"@llm4ts/core": "0.
|
|
89
|
+
"@llm4ts/core": "0.14.0"
|
|
87
90
|
},
|
|
88
91
|
"peerDependencies": {
|
|
89
92
|
"effect": "4.0.0-beta.102"
|
package/src/AzureDevOpsTool.ts
CHANGED
|
@@ -421,6 +421,103 @@ export const relationAddArgs = (
|
|
|
421
421
|
...json
|
|
422
422
|
]
|
|
423
423
|
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Work item links
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
// Azure DevOps models "this is part of that" and "this waits for that" as
|
|
429
|
+
// first-class links, where a GitHub issue has only prose. A consumer that
|
|
430
|
+
// writes `Parent: #12` into a description is describing a relationship the
|
|
431
|
+
// board can hold properly — and only the prose version shows up in the
|
|
432
|
+
// backlog tree, the dependency view, or a query.
|
|
433
|
+
export const WorkItemLinkKind = Schema.Literals([
|
|
434
|
+
"Parent",
|
|
435
|
+
"Child",
|
|
436
|
+
"Related",
|
|
437
|
+
"Predecessor",
|
|
438
|
+
"Successor"
|
|
439
|
+
])
|
|
440
|
+
export type WorkItemLinkKind = typeof WorkItemLinkKind.Type
|
|
441
|
+
|
|
442
|
+
export const workItemLinkKinds: ReadonlyArray<WorkItemLinkKind> = [
|
|
443
|
+
"Parent",
|
|
444
|
+
"Child",
|
|
445
|
+
"Related",
|
|
446
|
+
"Predecessor",
|
|
447
|
+
"Successor"
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
export class WorkItemLink extends Schema.Class<WorkItemLink>("WorkItemLink")({
|
|
451
|
+
kind: WorkItemLinkKind,
|
|
452
|
+
id: Schema.Int
|
|
453
|
+
}) {}
|
|
454
|
+
|
|
455
|
+
// Forward is the one that points away from the "primary" end: a parent's
|
|
456
|
+
// link to its child is Hierarchy-Forward, so a child's link to its parent
|
|
457
|
+
// is the Reverse. Dependency reads the same way — an item's Predecessor is
|
|
458
|
+
// the one it waits for, which is what "blocked by" means.
|
|
459
|
+
const linkReferenceNames: Readonly<Record<WorkItemLinkKind, string>> = {
|
|
460
|
+
Parent: "System.LinkTypes.Hierarchy-Reverse",
|
|
461
|
+
Child: "System.LinkTypes.Hierarchy-Forward",
|
|
462
|
+
Related: "System.LinkTypes.Related",
|
|
463
|
+
Predecessor: "System.LinkTypes.Dependency-Reverse",
|
|
464
|
+
Successor: "System.LinkTypes.Dependency-Forward"
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export const linkReferenceName = (kind: WorkItemLinkKind): string => linkReferenceNames[kind]
|
|
468
|
+
|
|
469
|
+
export const linkKindOfReference = (reference: string): WorkItemLinkKind | undefined =>
|
|
470
|
+
workItemLinkKinds.find(
|
|
471
|
+
(kind) => linkReferenceNames[kind].toLowerCase() === reference.trim().toLowerCase()
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
// A relation's url addresses the work item through the REST API, whatever
|
|
475
|
+
// the organization's host: the id is its last segment.
|
|
476
|
+
export const workItemIdOfUrl = (url: string): number | undefined => {
|
|
477
|
+
const match = /\/workItems\/(\d+)$/i.exec(url.trim())
|
|
478
|
+
const digits = match?.[1]
|
|
479
|
+
return digits === undefined ? undefined : Number(digits)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export const parseWorkItemLinks = (
|
|
483
|
+
payload: string
|
|
484
|
+
): Effect.Effect<ReadonlyArray<WorkItemLink>, ProcessError> =>
|
|
485
|
+
Schema.decodeUnknownEffect(Schema.fromJsonString(AdoRelations))(payload).pipe(
|
|
486
|
+
Effect.map((parsed) =>
|
|
487
|
+
(parsed.relations ?? []).flatMap((relation) => {
|
|
488
|
+
const kind = linkKindOfReference(relation.rel ?? "")
|
|
489
|
+
const id = workItemIdOfUrl(relation.url ?? "")
|
|
490
|
+
// Artifact links and hyperlinks share the relations array; they are
|
|
491
|
+
// skipped rather than half-decoded, as developmentLinks skips these.
|
|
492
|
+
return kind === undefined || id === undefined ? [] : [WorkItemLink.make({ kind, id })]
|
|
493
|
+
})
|
|
494
|
+
),
|
|
495
|
+
Effect.mapError(decodeFailure("az boards work-item show --expand relations"))
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
// Work item links take --target-id, where an artifact link takes the
|
|
499
|
+
// --target-url of a vstfs: URI. The CLI resolves --relation-type against
|
|
500
|
+
// the organization's own link types by name.
|
|
501
|
+
export const workItemLinkArgs = (
|
|
502
|
+
config: AdoConfig,
|
|
503
|
+
id: number,
|
|
504
|
+
kind: WorkItemLinkKind,
|
|
505
|
+
targetId: number
|
|
506
|
+
): ReadonlyArray<string> => [
|
|
507
|
+
"boards",
|
|
508
|
+
"work-item",
|
|
509
|
+
"relation",
|
|
510
|
+
"add",
|
|
511
|
+
"--id",
|
|
512
|
+
String(id),
|
|
513
|
+
"--relation-type",
|
|
514
|
+
kind.toLowerCase(),
|
|
515
|
+
"--target-id",
|
|
516
|
+
String(targetId),
|
|
517
|
+
...org(config),
|
|
518
|
+
...json
|
|
519
|
+
]
|
|
520
|
+
|
|
424
521
|
export const repositoryShowArgs = (
|
|
425
522
|
config: AdoConfig,
|
|
426
523
|
repository: string
|
|
@@ -731,6 +828,14 @@ export interface AzureDevOpsToolShape {
|
|
|
731
828
|
// commits linked to it. Empty when nothing has been linked yet.
|
|
732
829
|
readonly developmentLinks: (id: number) => Effect.Effect<ReadonlyArray<GitArtifact>, FlowError>
|
|
733
830
|
readonly linkArtifact: (id: number, artifact: GitArtifact) => Effect.Effect<void, FlowError>
|
|
831
|
+
// The work item's own links — hierarchy and dependency — as opposed to
|
|
832
|
+
// the git objects developmentLinks returns from the same call.
|
|
833
|
+
readonly workItemLinks: (id: number) => Effect.Effect<ReadonlyArray<WorkItemLink>, FlowError>
|
|
834
|
+
readonly linkWorkItem: (
|
|
835
|
+
id: number,
|
|
836
|
+
kind: WorkItemLinkKind,
|
|
837
|
+
targetId: number
|
|
838
|
+
) => Effect.Effect<void, FlowError>
|
|
734
839
|
// Resolves a repository's GUIDs, which every artifact link needs and no
|
|
735
840
|
// caller can know from a repository name alone.
|
|
736
841
|
readonly repository: (name?: string) => Effect.Effect<GitRepository, FlowError>
|
|
@@ -863,6 +968,16 @@ export const makeAzureDevOpsTool = (
|
|
|
863
968
|
),
|
|
864
969
|
linkArtifact: (id, artifact) =>
|
|
865
970
|
write("ado linkArtifact", run(relationAddArgs(config, id, artifact)).pipe(Effect.asVoid)),
|
|
971
|
+
workItemLinks: (id) =>
|
|
972
|
+
read(
|
|
973
|
+
"ado workItemLinks",
|
|
974
|
+
run(workItemShowArgs(config, id, "relations")).pipe(Effect.flatMap(parseWorkItemLinks))
|
|
975
|
+
),
|
|
976
|
+
linkWorkItem: (id, kind, targetId) =>
|
|
977
|
+
write(
|
|
978
|
+
"ado linkWorkItem",
|
|
979
|
+
run(workItemLinkArgs(config, id, kind, targetId)).pipe(Effect.asVoid)
|
|
980
|
+
),
|
|
866
981
|
repository: (name = config.repository) =>
|
|
867
982
|
read(
|
|
868
983
|
"ado repository",
|
package/src/BoardSync.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Ref from "effect/Ref"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import { quoteWiql, type AzureDevOpsToolShape } from "./AzureDevOpsTool.ts"
|
|
5
|
+
import { FlowAborted, type FlowError } from "./FlowError.ts"
|
|
6
|
+
import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
|
|
7
|
+
|
|
8
|
+
// The progress board of the conversion scenario: a port with the work-item
|
|
9
|
+
// lifecycle (plan → start → complete/fail/skip) and two adapters — a local
|
|
10
|
+
// file board (the default, fully offline: board.json + rendered board.md)
|
|
11
|
+
// and an Azure DevOps adapter (the stretch goal) mapping the same lifecycle
|
|
12
|
+
// onto work items. Flows depend only on the port; which board the client
|
|
13
|
+
// sees is wiring, not policy.
|
|
14
|
+
|
|
15
|
+
export const BoardVersion = 1
|
|
16
|
+
|
|
17
|
+
export const BoardStatus = Schema.Literals(["planned", "active", "done", "failed", "skipped"])
|
|
18
|
+
export type BoardStatus = typeof BoardStatus.Type
|
|
19
|
+
|
|
20
|
+
export class BoardItem extends Schema.Class<BoardItem>("BoardItem")({
|
|
21
|
+
id: Schema.String,
|
|
22
|
+
title: Schema.String,
|
|
23
|
+
wave: Schema.optionalKey(Schema.String),
|
|
24
|
+
status: BoardStatus,
|
|
25
|
+
branch: Schema.optionalKey(Schema.String),
|
|
26
|
+
reportPath: Schema.optionalKey(Schema.String),
|
|
27
|
+
/** Free-form note: a failure reason, a skip rationale, a triage disposition. */
|
|
28
|
+
detail: Schema.optionalKey(Schema.String),
|
|
29
|
+
/** ESTIMATES, never measurements — see EstimatedUsage (ADR 0012). */
|
|
30
|
+
estimatedTokens: Schema.optionalKey(Schema.Int),
|
|
31
|
+
estimatedCostUsd: Schema.optionalKey(Schema.Number)
|
|
32
|
+
}) {}
|
|
33
|
+
|
|
34
|
+
export class Board extends Schema.Class<Board>("Board")({
|
|
35
|
+
title: Schema.String,
|
|
36
|
+
items: Schema.Array(BoardItem)
|
|
37
|
+
}) {}
|
|
38
|
+
|
|
39
|
+
export interface BoardItemResult {
|
|
40
|
+
readonly branch?: string
|
|
41
|
+
readonly reportPath?: string
|
|
42
|
+
readonly detail?: string
|
|
43
|
+
readonly estimatedTokens?: number
|
|
44
|
+
readonly estimatedCostUsd?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BoardSyncShape {
|
|
48
|
+
/** Publish the full planned work list. Idempotent: known ids keep their state. */
|
|
49
|
+
readonly plan: (items: ReadonlyArray<BoardItem>) => Effect.Effect<void, FlowError>
|
|
50
|
+
readonly start: (id: string) => Effect.Effect<void, FlowError>
|
|
51
|
+
readonly complete: (id: string, result: BoardItemResult) => Effect.Effect<void, FlowError>
|
|
52
|
+
readonly fail: (id: string, reason: string) => Effect.Effect<void, FlowError>
|
|
53
|
+
readonly skip: (id: string, reason: string) => Effect.Effect<void, FlowError>
|
|
54
|
+
readonly snapshot: Effect.Effect<Board, FlowError>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sectionOrder: ReadonlyArray<readonly [BoardStatus, string]> = [
|
|
58
|
+
["active", "Active"],
|
|
59
|
+
["planned", "Planned"],
|
|
60
|
+
["done", "Done"],
|
|
61
|
+
["failed", "Failed"],
|
|
62
|
+
["skipped", "Skipped"]
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const itemLine = (item: BoardItem): string => {
|
|
66
|
+
const parts: Array<string> = [`- **${item.id}** — ${item.title}`]
|
|
67
|
+
if (item.wave !== undefined) {
|
|
68
|
+
parts.push(`(wave: ${item.wave})`)
|
|
69
|
+
}
|
|
70
|
+
if (item.branch !== undefined) {
|
|
71
|
+
parts.push(`branch \`${item.branch}\``)
|
|
72
|
+
}
|
|
73
|
+
if (item.reportPath !== undefined) {
|
|
74
|
+
parts.push(`[report](${item.reportPath})`)
|
|
75
|
+
}
|
|
76
|
+
if (item.estimatedTokens !== undefined || item.estimatedCostUsd !== undefined) {
|
|
77
|
+
const tokens = item.estimatedTokens === undefined ? "" : `~${item.estimatedTokens} tokens`
|
|
78
|
+
const cost = item.estimatedCostUsd === undefined ? "" : `~$${item.estimatedCostUsd.toFixed(2)}`
|
|
79
|
+
parts.push(`(${[tokens, cost].filter((part) => part.length > 0).join(", ")} — estimated)`)
|
|
80
|
+
}
|
|
81
|
+
if (item.detail !== undefined) {
|
|
82
|
+
parts.push(`— ${item.detail}`)
|
|
83
|
+
}
|
|
84
|
+
return parts.join(" ")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The markdown board — every figure on it labelled estimated. */
|
|
88
|
+
export const renderBoard = (board: Board): string => {
|
|
89
|
+
const lines: Array<string> = [`# Board: ${board.title}`, ""]
|
|
90
|
+
const counts = sectionOrder
|
|
91
|
+
.map(([status]) => `${status}: ${board.items.filter((item) => item.status === status).length}`)
|
|
92
|
+
.join(" · ")
|
|
93
|
+
lines.push(counts, "")
|
|
94
|
+
lines.push(
|
|
95
|
+
"All token and cost figures are ESTIMATES (character-count heuristics), not",
|
|
96
|
+
"measurements.",
|
|
97
|
+
""
|
|
98
|
+
)
|
|
99
|
+
for (const [status, heading] of sectionOrder) {
|
|
100
|
+
const items = board.items.filter((item) => item.status === status)
|
|
101
|
+
if (items.length === 0) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
lines.push(`## ${heading}`, "")
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
lines.push(itemLine(item))
|
|
107
|
+
}
|
|
108
|
+
lines.push("")
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n").trimEnd() + "\n"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const join = (root: string, path: string): string =>
|
|
114
|
+
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
115
|
+
|
|
116
|
+
const applyResult = (item: BoardItem, status: BoardStatus, result: BoardItemResult): BoardItem =>
|
|
117
|
+
BoardItem.make({
|
|
118
|
+
...item,
|
|
119
|
+
status,
|
|
120
|
+
...(result.branch === undefined ? {} : { branch: result.branch }),
|
|
121
|
+
...(result.reportPath === undefined ? {} : { reportPath: result.reportPath }),
|
|
122
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
123
|
+
...(result.estimatedTokens === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { estimatedTokens: Math.round(result.estimatedTokens) }),
|
|
126
|
+
...(result.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: result.estimatedCostUsd })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The default adapter: `board.json` (versioned) plus a rendered `board.md`
|
|
131
|
+
* under `directory`. The JSON is the state, the markdown is the demo surface;
|
|
132
|
+
* both are rewritten on every mutation so a crash never leaves them apart.
|
|
133
|
+
*/
|
|
134
|
+
export const makeLocalBoardSync = (
|
|
135
|
+
files: PlainFileStoreShape,
|
|
136
|
+
directory: string,
|
|
137
|
+
title: string
|
|
138
|
+
): BoardSyncShape => {
|
|
139
|
+
const jsonPath = join(directory, "board.json")
|
|
140
|
+
const markdownPath = join(directory, "board.md")
|
|
141
|
+
|
|
142
|
+
const load: Effect.Effect<Board, FlowError> = loadVersioned(
|
|
143
|
+
files,
|
|
144
|
+
jsonPath,
|
|
145
|
+
BoardVersion,
|
|
146
|
+
Board
|
|
147
|
+
).pipe(Effect.map((board) => board ?? Board.make({ title, items: [] })))
|
|
148
|
+
|
|
149
|
+
const save = (board: Board): Effect.Effect<void, FlowError> =>
|
|
150
|
+
saveVersioned(files, jsonPath, BoardVersion, Board, board).pipe(
|
|
151
|
+
Effect.andThen(files.writeAtomic(markdownPath, renderBoard(board)))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const update = (
|
|
155
|
+
id: string,
|
|
156
|
+
transform: (item: BoardItem) => BoardItem
|
|
157
|
+
): Effect.Effect<void, FlowError> =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const board = yield* load
|
|
160
|
+
if (!board.items.some((item) => item.id === id)) {
|
|
161
|
+
return yield* FlowAborted.make({ message: `board has no item '${id}' — plan it first` })
|
|
162
|
+
}
|
|
163
|
+
yield* save(
|
|
164
|
+
Board.make({
|
|
165
|
+
...board,
|
|
166
|
+
items: board.items.map((item) => (item.id === id ? transform(item) : item))
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
plan: (items) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const board = yield* load
|
|
175
|
+
const known = new Map(board.items.map((item) => [item.id, item] as const))
|
|
176
|
+
// Known ids keep their lived state — re-planning must never demote a
|
|
177
|
+
// converted page back to "planned".
|
|
178
|
+
const merged = [...board.items, ...items.filter((item) => !known.has(item.id))]
|
|
179
|
+
yield* save(Board.make({ title: board.title, items: merged }))
|
|
180
|
+
}),
|
|
181
|
+
start: (id) => update(id, (item) => BoardItem.make({ ...item, status: "active" })),
|
|
182
|
+
complete: (id, result) => update(id, (item) => applyResult(item, "done", result)),
|
|
183
|
+
fail: (id, reason) =>
|
|
184
|
+
update(id, (item) => BoardItem.make({ ...item, status: "failed", detail: reason })),
|
|
185
|
+
skip: (id, reason) =>
|
|
186
|
+
update(id, (item) => BoardItem.make({ ...item, status: "skipped", detail: reason })),
|
|
187
|
+
snapshot: load
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fan one lifecycle out to several boards (local file + ADO, typically).
|
|
193
|
+
* Mutations hit every board in order; `snapshot` reads the FIRST — the local
|
|
194
|
+
* board is the source of truth, the others are mirrors.
|
|
195
|
+
*/
|
|
196
|
+
export const composeBoardSync = (boards: ReadonlyArray<BoardSyncShape>): BoardSyncShape => {
|
|
197
|
+
const first = boards[0]
|
|
198
|
+
const each = (
|
|
199
|
+
operation: (board: BoardSyncShape) => Effect.Effect<void, FlowError>
|
|
200
|
+
): Effect.Effect<void, FlowError> => Effect.forEach(boards, operation, { discard: true })
|
|
201
|
+
return {
|
|
202
|
+
plan: (items) => each((board) => board.plan(items)),
|
|
203
|
+
start: (id) => each((board) => board.start(id)),
|
|
204
|
+
complete: (id, result) => each((board) => board.complete(id, result)),
|
|
205
|
+
fail: (id, reason) => each((board) => board.fail(id, reason)),
|
|
206
|
+
skip: (id, reason) => each((board) => board.skip(id, reason)),
|
|
207
|
+
snapshot:
|
|
208
|
+
first === undefined
|
|
209
|
+
? Effect.succeed(Board.make({ title: "empty", items: [] }))
|
|
210
|
+
: first.snapshot
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface AdoBoardOptions {
|
|
215
|
+
/** Work item type created for each page. Default "Task". */
|
|
216
|
+
readonly workItemType?: string
|
|
217
|
+
/** Tag identifying this board's items — the idempotency key. Default "llm4ts-convert". */
|
|
218
|
+
readonly tag?: string
|
|
219
|
+
/** Lifecycle → System.State mapping. Defaults: New / Active / Closed. */
|
|
220
|
+
readonly states?: {
|
|
221
|
+
readonly planned?: string
|
|
222
|
+
readonly active?: string
|
|
223
|
+
readonly done?: string
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const markerQuery = (tag: string, id: string): string =>
|
|
228
|
+
"SELECT [System.Id] FROM WorkItems WHERE " +
|
|
229
|
+
`[System.Tags] CONTAINS ${quoteWiql(tag)} AND ` +
|
|
230
|
+
`[System.Title] CONTAINS ${quoteWiql(`[${id}]`)}`
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The stretch adapter (ADR 0012): the same lifecycle mapped onto Azure DevOps
|
|
234
|
+
* work items through the az-CLI AzureDevOpsTool (ADR 0011). Idempotent by
|
|
235
|
+
* title marker — `[<id>]` plus the board tag — so a re-run finds its items
|
|
236
|
+
* instead of duplicating them. Failure and skip are tags plus a comment,
|
|
237
|
+
* never invented states.
|
|
238
|
+
*/
|
|
239
|
+
export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(function* (
|
|
240
|
+
ado: AzureDevOpsToolShape,
|
|
241
|
+
boardTitle: string,
|
|
242
|
+
options: AdoBoardOptions = {}
|
|
243
|
+
): Effect.fn.Return<BoardSyncShape> {
|
|
244
|
+
const workItemType = options.workItemType ?? "Task"
|
|
245
|
+
const tag = options.tag ?? "llm4ts-convert"
|
|
246
|
+
const states = {
|
|
247
|
+
planned: options.states?.planned ?? "New",
|
|
248
|
+
active: options.states?.active ?? "Active",
|
|
249
|
+
done: options.states?.done ?? "Closed"
|
|
250
|
+
}
|
|
251
|
+
const ids = yield* Ref.make<ReadonlyMap<string, number>>(new Map())
|
|
252
|
+
|
|
253
|
+
const lookup = (id: string): Effect.Effect<number, FlowError> =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
const cached = (yield* Ref.get(ids)).get(id)
|
|
256
|
+
if (cached !== undefined) {
|
|
257
|
+
return cached
|
|
258
|
+
}
|
|
259
|
+
const found = yield* ado.wiqlIds(markerQuery(tag, id))
|
|
260
|
+
const first = found[0]
|
|
261
|
+
if (first === undefined) {
|
|
262
|
+
return yield* FlowAborted.make({
|
|
263
|
+
message: `no ADO work item tagged '${tag}' with '[${id}]' in its title — plan it first`
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
yield* Ref.update(ids, (current) => new Map(current).set(id, first))
|
|
267
|
+
return first
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
plan: (items) =>
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
for (const item of items) {
|
|
274
|
+
const existing = yield* ado.wiqlIds(markerQuery(tag, item.id))
|
|
275
|
+
const first = existing[0]
|
|
276
|
+
if (first !== undefined) {
|
|
277
|
+
yield* Ref.update(ids, (current) => new Map(current).set(item.id, first))
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
const created = yield* ado.createWorkItem(
|
|
281
|
+
workItemType,
|
|
282
|
+
`[${item.id}] ${item.title}`,
|
|
283
|
+
item.detail ?? item.title,
|
|
284
|
+
[tag]
|
|
285
|
+
)
|
|
286
|
+
yield* Ref.update(ids, (current) => new Map(current).set(item.id, created.id))
|
|
287
|
+
if (states.planned !== "New") {
|
|
288
|
+
yield* ado.setState(created.id, states.planned)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
start: (id) =>
|
|
293
|
+
Effect.gen(function* () {
|
|
294
|
+
const workItem = yield* lookup(id)
|
|
295
|
+
yield* ado.setState(workItem, states.active)
|
|
296
|
+
}),
|
|
297
|
+
complete: (id, result) =>
|
|
298
|
+
Effect.gen(function* () {
|
|
299
|
+
const workItem = yield* lookup(id)
|
|
300
|
+
const parts = [
|
|
301
|
+
result.branch === undefined ? undefined : `Branch: ${result.branch}`,
|
|
302
|
+
result.reportPath === undefined ? undefined : `Report: ${result.reportPath}`,
|
|
303
|
+
result.estimatedTokens === undefined
|
|
304
|
+
? undefined
|
|
305
|
+
: `~${Math.round(result.estimatedTokens)} tokens (estimated)`,
|
|
306
|
+
result.estimatedCostUsd === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: `~$${result.estimatedCostUsd.toFixed(2)} (estimated)`,
|
|
309
|
+
result.detail
|
|
310
|
+
].filter((part): part is string => part !== undefined)
|
|
311
|
+
if (parts.length > 0) {
|
|
312
|
+
yield* ado.writeComment(workItem, parts.join("\n"))
|
|
313
|
+
}
|
|
314
|
+
yield* ado.setState(workItem, states.done)
|
|
315
|
+
}),
|
|
316
|
+
fail: (id, reason) =>
|
|
317
|
+
Effect.gen(function* () {
|
|
318
|
+
const workItem = yield* lookup(id)
|
|
319
|
+
yield* ado.editTags(workItem, [`${tag}-failed`], [])
|
|
320
|
+
yield* ado.writeComment(workItem, `FAILED: ${reason}`)
|
|
321
|
+
}),
|
|
322
|
+
skip: (id, reason) =>
|
|
323
|
+
Effect.gen(function* () {
|
|
324
|
+
const workItem = yield* lookup(id)
|
|
325
|
+
yield* ado.editTags(workItem, [`${tag}-skipped`], [])
|
|
326
|
+
yield* ado.writeComment(workItem, `SKIPPED: ${reason}`)
|
|
327
|
+
}),
|
|
328
|
+
snapshot: Effect.gen(function* () {
|
|
329
|
+
const found = yield* ado.wiqlIds(
|
|
330
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS ${quoteWiql(tag)}`
|
|
331
|
+
)
|
|
332
|
+
const items: Array<BoardItem> = []
|
|
333
|
+
for (const workItemId of found) {
|
|
334
|
+
const workItem = yield* ado.readWorkItem(workItemId)
|
|
335
|
+
const match = /^\[([^\]]+)\] (.*)$/.exec(workItem.title)
|
|
336
|
+
const failed = workItem.tags.includes(`${tag}-failed`)
|
|
337
|
+
const skipped = workItem.tags.includes(`${tag}-skipped`)
|
|
338
|
+
const status: BoardStatus = failed
|
|
339
|
+
? "failed"
|
|
340
|
+
: skipped
|
|
341
|
+
? "skipped"
|
|
342
|
+
: workItem.state === states.done
|
|
343
|
+
? "done"
|
|
344
|
+
: workItem.state === states.active
|
|
345
|
+
? "active"
|
|
346
|
+
: "planned"
|
|
347
|
+
items.push(
|
|
348
|
+
BoardItem.make({
|
|
349
|
+
id: match?.[1] ?? String(workItemId),
|
|
350
|
+
title: match?.[2] ?? workItem.title,
|
|
351
|
+
status
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
return Board.make({ title: boardTitle, items })
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
})
|