← Back to projects
Build log & case study — closed project

CPM Engine

A Symfony REST API that runs the Critical Path Method over a GitHub repo's commit graph — and the record of building it, hitting a conceptual wall, and shipping the honest version instead of the intended one.

Apr 6 → Jul 1
2026, 6 build days
17
commits
12
unit tests, 4 files
Closed
portfolio artifact
01

What it does

Each commit in a GitHub repo becomes an activity. Each parent→child relationship becomes a dependency. The engine runs a topological sort, forward pass, and backward pass over that graph — the same three passes any CPM textbook teaches for a construction schedule — and reports which commits sat on the critical chain and which branches had slack before they needed to merge.

Merge commits create fan-in edges, so parallel branches surface as parallel paths in the network diagram, not a flattened line.

Eight-activity critical path network from the test fixture A 0–3 B 3–7 C slack 3 D slack 9 E 7–12 F 12–15 G 15–19 H 19–21
critical path (slack = 0) slack — not critical
CriticalPathServiceTest::testEightActivityExample — project duration 21, critical path A→B→E→F→G→H. This exact case is what the unit test asserts.
02

Build log

Six sessions, three months elapsed, in the order they actually happened — not a plan written in advance.

2026-04-06
Scaffold, entities, first README
  • 630d7a6 Symfony webapp scaffold with SQLite configuration
  • 32aa06c Add Project and Activity entities with Doctrine migration
  • 915fb35 / ae967d2 Documentation and README pass
2026-04-11
Docs restructure
  • ee2b02d Overhauling documentation according to optimal template for better readability
2026-04-20
The missing entity
  • 670dec2 Add ActivityDependency, with incoming / outgoing collections on Activity
2026-04-29
The algorithm — and a caught bug
  • 7c0e004 Activity dependency table for Critical Path Service
  • d67141e Implement Critical Path Service — forward/backward pass, slack, tests
  • 582edfa Clean up comments
  • e979bd0 Updating agent progress

The test above (testEightActivityExample) is the one that shipped here. The known-issues log from this session records a docs typo caught against it: activity D's slack was written as 6, the test says 9LS(12) − ES(3). The test was the tiebreaker, not the doc.

→ 43-day gap before the next commit
2026-06-11
API surface and a working demo
  • 1db309f Update docs and dependencies
  • d61d39b Add project API endpoints with demo fixtures
  • 0189be0 / 3af297f Allow demo projects without repo URL (same message, twice — a redo, not a duplicate)
  • 51c1453 Static demo page for visualized project flow
→ 20-day gap before the next commit
2026-07-01
GitHub import, then closing the loop
  • 11ca1e1 Add GitHub commit import — URL parser, commit fetcher, activity mapper
  • 8d8e2f4 Closing out with polished demo and reframed for retrospective instead of prospective analysis
03

The case study: what broke, on the last day

The GitHub importer was the last feature, and it's also what surfaced the project's actual limit. CPM is a prospective planning method — it needs someone to estimate durations before work starts, so a schedule can be optimized against those estimates. Commit history is unavoidably retrospective: it only records what already happened. No amount of API polish closes that gap without a person supplying estimates by hand, which isn't what a GitHub importer is for.

What changed in the same commit

Rather than keep the "can't slip," "breathing room," "minimum time to finish" planning language and let it misdescribe what the tool actually does, the last commit renamed the vocabulary: critical chain, had buffer, total elapsed time. The Mermaid diagram was also cut down from all ~100 commit edges to just the critical-path edges, and the transcript-parser UI for "describe any project" was removed — it called an endpoint, /api/projects/parse, that had never been implemented.

The alternative was extending the data model — issues, PRs, and manual duration input as a second, prospective input path alongside the commit importer. That would have made the tool actually do PM planning again. It was scoped out on purpose: the entity model (Project → Activity → ActivityDependency) and the mapper were built around one shape of input, commit graphs, and bending that into two incompatible planning modes was a rewrite, not a feature.

"This mismatch was identified during development. The project was closed out as a working portfolio artifact rather than extended into something it couldn't be." — README, Status

Closing a project on purpose is a decision, not a stall. The engine and the importer both work end-to-end against real repos; the thing that doesn't exist is a feature the tool was never actually positioned to have.

04

How it's put together

Projectid, name, repoUrl
──▷
Activityid, name, duration
──▷
ActivityDependencypredecessor, successor

ES / EF / LS / LF / slack are computed on request, never persisted — CriticalPathService is a pure function over activities and dependencies, so re-running it against edited data can't drift from stored numbers because there are none to drift.

topologicalSort()
forwardPass()
backwardPass()
buildResult()
// GitHubActivityMapper — duration is elapsed time, not effort
$latestParentDate = max(array_map(fn($sha) => $dates[$sha], $inWindowParents));
$seconds  = $dates[$commit['sha']]->getTimestamp() - $latestParentDate->getTimestamp();
$duration = (float) max(0, (int) round($seconds / 3600));

A commit's "duration" is the rounded hours since its latest in-window parent — the same elapsed-vs-effort gap the build log above shows by hand: a 43-day gap between sessions isn't 43 days of work, and neither is a commit dated 43 days after its parent.

API surface — 5 routes, all in the README
Method & pathReturns
GET /api/projectsAll imported projects
GET /api/projects/{id}Project name and id
GET /api/projects/{id}/critical-pathES, EF, LS, LF, slack, is_critical per activity
GET /api/projects/{id}/network-diagramNodes, edges, Mermaid string, project duration
POST /api/github/importParses a repo URL, fetches commits, persists the graph — 400/404/502 on bad input, missing repo, or GitHub API failure
05

Known limitations, stated on purpose

Documented in the README rather than discovered by a user — these are the edges of v0, not bugs:

06

Retrospective

Held up

  • Textbook CPM (Kahn's-algorithm topo sort, forward/backward pass) implemented once and never touched again after 2026-04-29
  • The eight-activity test fixture caught a real docs error before it shipped
  • Import verified end-to-end against three real repos, not just fixtures

Would do differently

  • Naming the prospective/retrospective mismatch on day one — it was checkable before any code, not just after the importer existed
  • A route-level test for /api/github/import, not just the mapper
  • URL normalization before re-import semantics, since one depends on the other