import { Schema, Data, Effect, Result, Context, Layer } from "effect" import { join, relative } from "node:path" import { GRAPH_VERSION, type AgencyGraph, type GraphBlocker, type GraphEdge, type GraphExecutionGit, type GraphExecutionWorkspace, type GraphInclude, type GraphNode, type GraphNodeKind, type GraphPr, type GraphRepositoryGit, } from "../graph-schema" import { aggregateProgress, isDependencySatisfied, readinessState, } from "../readiness" import { parseFrontmatter } from "../workbase/frontmatter" import { normalizePullRequestRecord } from "../workbase/delivery-command" import { documentRevision } from "../workbase/document-revision" import { documentLoadConcurrency } from "../workbase/document-loading" import { EpicFrontmatter, PhaseFrontmatter, TaskFrontmatter, type Dependency, type EpicFrontmatter as EpicData, type PhaseFrontmatter as PhaseData, type TaskFrontmatter as TaskData, type WorkbaseConfig as WorkbaseConfigData, type WorkStatus, } from "../workbase/schemas" import { FileSystemService } from "./FileSystemService" import { TicketService } from "./TicketService" import { VersionControlService, type VersionControlBackend, } from "./VersionControlService" import { WorkbaseService, type ValidationReport } from "./WorkbaseService" class GraphError extends Data.TaggedError("GraphError")<{ readonly message: string readonly path?: string }> {} interface Document { readonly id: string readonly path: string readonly sha256: string readonly data: T readonly body: string } interface RepositoryRecord { readonly alias: string readonly path: string readonly target: string | null } type ExecutionData = | PhaseData | Extract | Extract export interface GraphOptions { readonly cwd?: string readonly loadedConfig?: { readonly root: string readonly config: WorkbaseConfigData } readonly validation?: ValidationReport readonly ready?: boolean readonly blocked?: boolean readonly statuses?: readonly WorkStatus[] readonly repositories?: readonly string[] readonly kinds?: readonly GraphNodeKind[] readonly include?: readonly GraphInclude[] readonly backend?: VersionControlBackend } const epicNodeId = (id: string) => `epic:${id}` const taskNodeId = (id: string) => `task:${id}` const phaseNodeId = (taskId: string, phaseId: string) => `phase:${taskId}/${phaseId}` const repositoryNodeId = (alias: string) => `repository:${alias}` const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}` const phaseExecutionNodeId = (taskId: string, phaseId: string) => `execution-unit:phase/${taskId}/${phaseId}` const decode = >( schema: S, input: unknown, ) => { const result = Schema.decodeUnknownResult(schema, { errors: "all", onExcessProperty: "error", })(input) return Result.isFailure(result) ? { ok: false as const, error: result.failure.message } : { ok: true as const, value: result.success } } const run = ( fs: FileSystemService["Service"], args: readonly string[], cwd?: string, ) => fs.runCommand(args, { cwd, captureOutput: true }).pipe( Effect.map((result) => result.exitCode === 0 ? result.stdout.trim() || null : null, ), Effect.catch(() => Effect.succeed(null)), ) const runText = ( fs: FileSystemService["Service"], args: readonly string[], cwd?: string, ) => fs.runCommand(args, { cwd, captureOutput: true }).pipe( Effect.map((result) => result.exitCode === 0 ? result.stdout.trim() : null, ), Effect.catch(() => Effect.succeed(null)), ) const edge = ( kind: GraphEdge["kind"], from: string, to: string, ): GraphEdge => ({ id: `${kind}:${from}->${to}`, kind, from, to }) export class GraphService extends Context.Service()( "GraphService", { make: Effect.sync(() => ({ get: (options: GraphOptions = {}) => Effect.gen(function* () { const fs = yield* FileSystemService const workbase = yield* WorkbaseService const { root, config } = options.loadedConfig ?? (yield* workbase.loadConfig(options.cwd)) const includes = [...new Set(options.include ?? [])].sort() const include = new Set(includes) const backend = include.has("git") ? (options.backend ?? (yield* VersionControlService.pipe( Effect.flatMap((service) => service.forWorkbase(root)), ))) : options.backend const epics = new Map>() const tasks = new Map>() const phases = new Map>() const directories = (path: string) => fs.readDirectory(path).pipe( Effect.map((entries) => entries .filter((entry) => entry.isDirectory) .map((entry) => entry.name) .sort(), ), Effect.catchTag("FileNotFoundError", () => Effect.succeed([])), ) const readDocument = >( id: string, path: string, schema: S, ) => Effect.gen(function* () { const content = yield* fs.readFile(path) const parsed = yield* parseFrontmatter(content, path).pipe( Effect.mapError( (error) => new GraphError({ path, message: error.message }), ), ) const decoded = decode(schema, parsed.data) if (!decoded.ok) { return yield* new GraphError({ path, message: `Invalid graph document ${path}:\n${decoded.error}`, }) } return { id, path, sha256: documentRevision(content), data: decoded.value, body: parsed.body, } satisfies Document }) const epicIds = yield* directories(join(root, "epics")) const epicDocuments = yield* Effect.all( epicIds.map((id) => Effect.gen(function* () { const path = join(root, "epics", id, "EPIC.md") return yield* readDocument(id, path, EpicFrontmatter).pipe( Effect.catchTag("FileNotFoundError", () => Effect.succeed(null), ), ) }), ), { concurrency: documentLoadConcurrency }, ) for (const document of epicDocuments) { if (document) epics.set(document.id, document) } const taskIds = yield* directories(join(root, "tasks")) const taskDocuments = yield* Effect.all( taskIds.map((id) => Effect.gen(function* () { const path = join(root, "tasks", id, "TASK.md") const task = yield* readDocument( id, path, TaskFrontmatter, ).pipe( Effect.catchTag("FileNotFoundError", () => Effect.succeed(null), ), ) const phaseIds = yield* directories( join(root, "tasks", id, "phases"), ) return { id, task, phaseIds } }), ), { concurrency: documentLoadConcurrency }, ) const phaseDocuments = yield* Effect.all( taskDocuments.flatMap(({ id, phaseIds }) => phaseIds.map((phaseId) => { const phasePath = join( root, "tasks", id, "phases", phaseId, "PHASE.md", ) return readDocument(phaseId, phasePath, PhaseFrontmatter).pipe( Effect.catchTag("FileNotFoundError", () => Effect.succeed(null), ), Effect.map((document) => ({ taskId: id, document })), ) }), ), { concurrency: documentLoadConcurrency }, ) for (const documents of taskDocuments) { if (documents.task) tasks.set(documents.id, documents.task) } for (const { taskId, document } of phaseDocuments) { if (document) phases.set(`${taskId}/${document.id}`, document) } const repositoryRecords = new Map() const reposPath = join(root, "repos") for (const alias of Object.keys(config.repositories ?? {}).sort()) { repositoryRecords.set(alias, { alias, path: join(reposPath, alias), target: null, }) } if (yield* fs.isDirectory(reposPath)) { for (const entry of (yield* fs.readDirectory(reposPath)) .filter((item) => !item.name.startsWith(".agency-")) .sort((a, b) => a.name.localeCompare(b.name))) { repositoryRecords.set(entry.name, { alias: entry.name, path: join(reposPath, entry.name), target: entry.isSymlink ? yield* fs.readSymlinkTarget(join(reposPath, entry.name)) : null, }) } } const taskDeclarations = new Map() const phasesByTask = new Map[]>() for (const [key, phase] of phases) { const taskId = key.slice(0, key.indexOf("/")) const values = phasesByTask.get(taskId) ?? [] values.push(phase) phasesByTask.set(taskId, values) } for (const epic of epics.values()) { for (const declaration of epic.data.tasks) { taskDeclarations.set(declaration.id, declaration) } } const taskDependencies = (taskId: string) => taskDeclarations.get(taskId)?.dependsOn ?? [] const taskLeafStatusCache = new Map() const taskLeafStatuses = (taskId: string): WorkStatus[] => { const cached = taskLeafStatusCache.get(taskId) if (cached) return cached const task = tasks.get(taskId) if (!task) return [] const statuses = "phases" in task.data ? task.data.phases.map( (item) => phases.get(`${taskId}/${item.id}`)?.data.status ?? "open", ) : [task.data.status] taskLeafStatusCache.set(taskId, statuses) return statuses } const taskStatusCache = new Map() const taskStatus = (taskId: string) => { const cached = taskStatusCache.get(taskId) if (cached) return cached const status = aggregateProgress(taskLeafStatuses(taskId)).status taskStatusCache.set(taskId, status) return status } const dependencyBlockers = ( dependencies: readonly string[], toId: (id: string) => string, status: (id: string) => WorkStatus | undefined, label: string, ): GraphBlocker[] => dependencies.flatMap((dependency) => { const value = status(dependency) return isDependencySatisfied(value) ? [] : [ { kind: "dependency" as const, id: toId(dependency), ...(value ? { status: value } : {}), reason: value ? `${label} dependency is ${value}` : `${label} dependency is missing`, }, ] }) const validation = options.validation ?? (yield* workbase.validate(root, { includeDocuments: true })) const validationIssuesByPath = Map.groupBy( validation.issues, (issue) => issue.path, ) const validationBlockers = ( paths: readonly string[], ): GraphBlocker[] => paths.flatMap((path) => (validationIssuesByPath.get(path) ?? []).map((issue) => ({ kind: "validation" as const, id: issue.path, reason: issue.message, })), ) const uniqueBlockers = (blockers: readonly GraphBlocker[]) => [ ...new Map( blockers.map((item) => [ `${item.kind}:${item.id}:${item.reason}`, item, ]), ).values(), ] type PhaseState = ReturnType const phaseStates = new Map() function createPhaseState(taskId: string, phaseId: string) { const task = tasks.get(taskId) const phase = phases.get(`${taskId}/${phaseId}`) const parentEpic = task?.data.epic ? epics.get(task.data.epic) : undefined const declaration = task && "phases" in task.data ? task.data.phases.find((item) => item.id === phaseId) : undefined const blockers = [ ...dependencyBlockers( declaration?.dependsOn ?? [], (id) => phaseNodeId(taskId, id), (id) => phases.get(`${taskId}/${id}`)?.data.status, "Phase", ), ...dependencyBlockers( taskDependencies(taskId), taskNodeId, (id) => (tasks.has(id) ? taskStatus(id) : undefined), "Parent task", ), ...validationBlockers( [phase?.path, task?.path, parentEpic?.path] .filter((path): path is string => Boolean(path)) .map((path) => relative(root, path)), ), ] const status = phase?.data.status ?? "open" if (status !== "open") { blockers.push({ kind: "status", id: phaseNodeId(taskId, phaseId), status, reason: `Phase status is ${status}`, }) } return { status, aggregate: aggregateProgress([status]), readiness: { ...readinessState(status, blockers), blockers: uniqueBlockers(blockers), }, } } const phaseState = (taskId: string, phaseId: string) => { const key = `${taskId}/${phaseId}` const cached = phaseStates.get(key) if (cached) return cached const state = createPhaseState(taskId, phaseId) phaseStates.set(key, state) return state } type TaskState = ReturnType const taskStates = new Map() function createTaskState(taskId: string) { const task = tasks.get(taskId) const parentEpic = task?.data.epic ? epics.get(task.data.epic) : undefined const statuses = taskLeafStatuses(taskId) const aggregate = aggregateProgress(statuses) const descendantPaths = task ? [ relative(root, task.path), ...(parentEpic ? [relative(root, parentEpic.path)] : []), ...(phasesByTask.get(taskId) ?? []).map((phase) => relative(root, phase.path), ), ] : [] const blockers = [ ...dependencyBlockers( taskDependencies(taskId), taskNodeId, (id) => (tasks.has(id) ? taskStatus(id) : undefined), "Task", ), ...validationBlockers(descendantPaths), ] let ready = false if (task && "phases" in task.data) { ready = blockers.every((item) => item.kind !== "validation") && taskDependencies(taskId).every( (id) => taskStatus(id) === "done", ) && task.data.phases.some( (item) => phaseState(taskId, item.id).readiness.ready, ) for (const item of task.data.phases) { const state = phaseState(taskId, item.id) blockers.push( ...state.readiness.blockers.filter( (blocker) => blocker.kind === "dependency" || blocker.kind === "validation", ), ) if (state.status === "dropped") { blockers.push({ kind: "status", id: phaseNodeId(taskId, item.id), status: "dropped", reason: "Child phase is dropped", }) } } } else if (task && "status" in task.data) { ready = task.data.status === "open" && blockers.length === 0 if (task.data.status !== "open") { blockers.push({ kind: "status", id: taskNodeId(taskId), status: task.data.status, reason: `Task status is ${task.data.status}`, }) } } const taskBlockers = uniqueBlockers(blockers) return { status: aggregate.status, aggregate, readiness: { ...readinessState(aggregate.status, taskBlockers, ready), blockers: taskBlockers, }, } } const taskState = (taskId: string) => { const cached = taskStates.get(taskId) if (cached) return cached const state = createTaskState(taskId) taskStates.set(taskId, state) return state } const epicState = (epicId: string) => { const epic = epics.get(epicId) const statuses = epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ?? [] const aggregate = aggregateProgress(statuses) const paths = epic ? [ relative(root, epic.path), ...epic.data.tasks.flatMap((item) => { const task = tasks.get(item.id) return [ ...(task ? [relative(root, task.path)] : []), ...(phasesByTask.get(item.id) ?? []).map((phase) => relative(root, phase.path), ), ] }), ] : [] const blockers = validationBlockers(paths) for (const item of epic?.data.tasks ?? []) { const childState = taskState(item.id) blockers.push( ...childState.readiness.blockers.filter( (blocker) => blocker.kind !== "status" || blocker.status === "dropped", ), ) if (taskStatus(item.id) === "dropped") { blockers.push({ kind: "status", id: taskNodeId(item.id), status: "dropped", reason: "Child task is dropped", }) } } const ready = !blockers.some((item) => item.kind === "validation") && Boolean( epic?.data.tasks.some( (item) => taskState(item.id).readiness.ready, ), ) const epicBlockers = uniqueBlockers(blockers) return { status: aggregate.status, aggregate, readiness: { ...readinessState(aggregate.status, epicBlockers, ready), blockers: epicBlockers, }, } } const edges: GraphEdge[] = [] for (const epic of epics.values()) { for (const item of epic.data.tasks) { edges.push(edge("owns", epicNodeId(epic.id), taskNodeId(item.id))) for (const dependency of item.dependsOn ?? []) { edges.push( edge( "depends_on", taskNodeId(item.id), taskNodeId(dependency), ), ) } } } for (const task of tasks.values()) { if ("phases" in task.data) { for (const item of task.data.phases) { edges.push( edge( "owns", taskNodeId(task.id), phaseNodeId(task.id, item.id), ), ) for (const dependency of item.dependsOn ?? []) { edges.push( edge( "depends_on", phaseNodeId(task.id, item.id), phaseNodeId(task.id, dependency), ), ) } } } else { edges.push( edge("owns", taskNodeId(task.id), taskExecutionNodeId(task.id)), ) } } for (const [key, phase] of phases) { const taskId = key.slice(0, key.indexOf("/")) edges.push( edge( "owns", phaseNodeId(taskId, phase.id), phaseExecutionNodeId(taskId, phase.id), ), ) } const executionRepositories = ( data: TaskData | PhaseData, ): readonly string[] => "review" in data ? [data.review.repo] : "repo" in data ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)] : [] const executionEdges = (id: string, data: TaskData | PhaseData) => { if ("review" in data) { edges.push( edge("references", id, repositoryNodeId(data.review.repo)), ) return } if (!("repo" in data)) return edges.push(edge("writes", id, repositoryNodeId(data.repo))) for (const reference of data.repos ?? []) { edges.push( edge("references", id, repositoryNodeId(reference.repo)), ) } } for (const epic of epics.values()) { for (const reference of epic.data.repos) { edges.push( edge( "references", epicNodeId(epic.id), repositoryNodeId(reference.repo), ), ) } } for (const task of tasks.values()) { if (!("phases" in task.data)) { executionEdges(taskExecutionNodeId(task.id), task.data) } } for (const [key, phase] of phases) { const taskId = key.slice(0, key.indexOf("/")) executionEdges(phaseExecutionNodeId(taskId, phase.id), phase.data) } const reverseDependencies = new Map() for (const item of edges.filter( (value) => value.kind === "depends_on", )) { const dependents = reverseDependencies.get(item.to) ?? [] dependents.push(item.from) reverseDependencies.set(item.to, dependents) } const dependents = (id: string) => [...(reverseDependencies.get(id) ?? [])].sort() const repositoryWorkspaces = new Map< string, ReturnType >() const listWorkspaces = (path: string) => { if (!backend) return Effect.succeed([]) const cached = repositoryWorkspaces.get(path) if (cached) return cached const workspaces = backend .listWorkspaces(path) .pipe(Effect.catch(() => Effect.succeed([]))) repositoryWorkspaces.set(path, workspaces) return workspaces } const repositoryRemotes = new Map() const remoteUrl = (path: string, remote: string) => Effect.gen(function* () { if (!backend) return null const key = `${path}\u0000${remote}` if (repositoryRemotes.has(key)) return repositoryRemotes.get(key)! const url = yield* backend.remoteUrl(path, remote) repositoryRemotes.set(key, url) return url }) const resolvedRevisions = new Map() const resolveRevision = (path: string, revision: string) => Effect.gen(function* () { if (!backend) return null const key = `${path}\u0000${revision}` if (resolvedRevisions.has(key)) return resolvedRevisions.get(key)! const resolved = yield* backend.resolveRevision(path, revision) resolvedRevisions.set(key, resolved) return resolved }) const documentDetails = >( document: Document, ) => ({ data: { ...document.data, sha256: document.sha256 }, ...(include.has("bodies") ? { body: document.body } : {}), ...(include.has("workspace") ? { workspace: { documentPath: document.path, directory: document.path.replace( /\/(?:EPIC|TASK|PHASE)\.md$/, "", ), }, } : {}), }) const inspectGit = (path: string) => Effect.gen(function* () { if (!backend) return undefined const inspection = yield* backend.inspectRepository(path) const workspaces = inspection ? yield* listWorkspaces(path) : [] const primary = workspaces.find( (workspace) => workspace.path === path, ) return { kind: inspection?.kind ?? null, remote: inspection?.remote ?? null, head: inspection ? yield* backend.workspaceHead(path) : null, branch: primary?.branch?.replace(/^refs\/heads\//, "") ?? null, } satisfies GraphRepositoryGit }) const executionDetails = (entityPath: string, data: ExecutionData) => Effect.gen(function* () { const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "") const repo = "review" in data ? data.review.repo : data.repo const checkoutPath = join(directory, "code", repo) const repositoryPath = join(root, "repos", repo) const materialized = yield* fs.isDirectory(checkoutPath) const result: { workspace?: GraphExecutionWorkspace git?: GraphExecutionGit pr?: GraphPr } = {} if (include.has("workspace")) { result.workspace = { codePath: join(directory, "code"), checkoutPath, materialized, } } if (include.has("git")) { if (!backend) return result const remote = yield* remoteUrl(repositoryPath, "origin") const canonicalCheckoutPath = materialized ? yield* fs.realPath(checkoutPath) : checkoutPath const actualBranch = materialized ? yield* listWorkspaces(repositoryPath).pipe( Effect.map( (workspaces) => workspaces .find( (workspace) => workspace.path === canonicalCheckoutPath, ) ?.branch?.replace(/^refs\/heads\//, "") ?? null, ), Effect.catch(() => Effect.succeed(null)), ) : null result.git = "review" in data ? { pinnedCommit: data.review.commit, sourceCommit: ((value) => value?.split(/\s+/)[0] ?? null)( yield* run(fs, [ "git", "ls-remote", remote ?? "origin", data.review.source.kind === "pull-request" ? data.review.source.fetchRef : data.review.source.ref .replace(/^refs\/remotes\/origin\//, "") .replace(/^origin\//, ""), ]), ), checkoutCommit: materialized ? yield* backend.workspaceHead(checkoutPath) : null, checkoutBranch: null, dirty: materialized ? yield* backend.workspaceDirty(checkoutPath) : null, } : { branch: data.branch, base: data.base, branchCommit: yield* resolveRevision( repositoryPath, data.branch, ), baseCommit: yield* resolveRevision( repositoryPath, data.base, ), checkoutCommit: materialized ? yield* backend.workspaceHead(checkoutPath) : null, checkoutBranch: actualBranch, dirty: materialized ? yield* backend.workspaceDirty(checkoutPath) : null, } } if (include.has("pr") && !("review" in data)) { if (!data.pr) { result.pr = { url: null, state: "none" } } else { result.pr = normalizePullRequestRecord(data.pr) } } return result }) const executionDetailsByKey = new Map< string, Effect.Success> >() const executionDocuments: readonly (readonly [ string, string, ExecutionData, ])[] = [ ...[...tasks.values()].flatMap((task) => "phases" in task.data ? [] : ([[task.id, task.path, task.data]] as const), ), ...[...phases].map( ([key, phase]) => [key, phase.path, phase.data] as const, ), ] const inspectedExecutions = yield* Effect.all( executionDocuments.map(([key, path, data]) => executionDetails(path, data).pipe( Effect.map((details) => [key, details] as const), ), ), { concurrency: 16 }, ) for (const [key, details] of inspectedExecutions) { executionDetailsByKey.set(key, details) } const nodes: GraphNode[] = [] for (const epic of epics.values()) { const state = epicState(epic.id) nodes.push({ id: epicNodeId(epic.id), kind: "epic", key: epic.id, ...state, dependents: dependents(epicNodeId(epic.id)), repositories: epic.data.repos.map((item) => item.repo).sort(), ...documentDetails(epic), }) } for (const task of tasks.values()) { const state = taskState(task.id) const repositories = "phases" in task.data ? [ ...new Set( task.data.phases.flatMap((item) => { const phase = phases.get(`${task.id}/${item.id}`) return phase ? executionRepositories(phase.data) : [] }), ), ].sort() : [...executionRepositories(task.data)].sort() nodes.push({ id: taskNodeId(task.id), kind: "task", key: task.id, ...state, dependents: dependents(taskNodeId(task.id)), repositories, ...documentDetails(task), }) if (!("phases" in task.data)) { nodes.push({ id: taskExecutionNodeId(task.id), kind: "execution-unit", key: `task/${task.id}`, ...state, dependents: dependents(taskNodeId(task.id)), repositories, data: { taskId: task.id, ...task.data }, ...executionDetailsByKey.get(task.id), }) } } for (const [key, phase] of phases) { const taskId = key.slice(0, key.indexOf("/")) const state = phaseState(taskId, phase.id) const repositories = [...executionRepositories(phase.data)].sort() nodes.push({ id: phaseNodeId(taskId, phase.id), kind: "phase", key, ...state, dependents: dependents(phaseNodeId(taskId, phase.id)), repositories, ...documentDetails(phase), }) nodes.push({ id: phaseExecutionNodeId(taskId, phase.id), kind: "execution-unit", key: `phase/${key}`, ...state, dependents: dependents(phaseNodeId(taskId, phase.id)), repositories, data: { taskId, phaseId: phase.id, ...phase.data }, ...executionDetailsByKey.get(key), }) } for (const repository of [...repositoryRecords.values()].sort( (a, b) => a.alias.localeCompare(b.alias), )) { nodes.push({ id: repositoryNodeId(repository.alias), kind: "repository", key: repository.alias, status: null, readiness: null, aggregate: null, dependents: [], repositories: [repository.alias], data: { alias: repository.alias }, ...(include.has("workspace") ? { workspace: { path: repository.path, target: repository.target, }, } : {}), ...(include.has("git") ? { git: yield* inspectGit(repository.path) } : {}), }) } const filters = { ready: options.ready ?? null, blocked: options.blocked ?? null, statuses: [...(options.statuses ?? [])].sort(), repositories: [...(options.repositories ?? [])].sort(), kinds: [...(options.kinds ?? [])].sort(), } const filteredNodes = nodes .filter( (node) => options.ready === undefined || node.readiness?.ready === options.ready, ) .filter( (node) => options.blocked === undefined || node.readiness?.blocked === options.blocked, ) .filter( (node) => !options.statuses?.length || (node.status !== null && options.statuses.includes(node.status)), ) .filter( (node) => !options.repositories?.length || node.repositories.some((alias) => options.repositories!.includes(alias), ), ) .filter( (node) => !options.kinds?.length || options.kinds.includes(node.kind), ) .sort((a, b) => a.id.localeCompare(b.id)) const ticketUrl = (node: GraphNode) => node.kind === "epic" || node.kind === "task" ? node.data.ticketUrl : node.kind === "execution-unit" ? (tasks.get(node.data.taskId)?.data.ticketUrl ?? null) : undefined const tickets = include.has("ticket") ? yield* TicketService.pipe( Effect.flatMap((service) => service.readMany(filteredNodes.map(ticketUrl)), ), ) : null const outputNodes: GraphNode[] = tickets ? filteredNodes.map((node) => { const url = ticketUrl(node) if (url === undefined) return node return { ...node, ticket: (url && tickets.get(url)) || { url: null, state: "none", }, } }) : filteredNodes const nodeIds = new Set(filteredNodes.map((node) => node.id)) const filteredEdges = edges .filter((item) => nodeIds.has(item.from) && nodeIds.has(item.to)) .sort((a, b) => a.id.localeCompare(b.id)) const leafStatuses = [ ...tasks .values() .flatMap((task) => "phases" in task.data ? [] : [task.data.status], ), ...phases.values().map((phase) => phase.data.status), ] return { version: GRAPH_VERSION, workbase: { version: config.version, vcs: "git", ...(include.has("workspace") ? { root } : {}), }, filters, includes, nodes: outputNodes, edges: filteredEdges, summary: aggregateProgress(leafStatuses), validation: { valid: validation.valid, issues: validation.issues, }, } satisfies AgencyGraph }), })), }, ) { static readonly layer = Layer.effect(this, this.make) }