Skip to content

Commit 4474a16

Browse files
committed
[feat.dirac.sigma.D9+] turn Program jobs into a visible delivery plan
A unique Work Item was technically governed but visually buried. Program now plans dates and dependencies once, renders the five-stage flow and a real Gantt, supports revision and movement without changing identity, and rejects impossible schedules or dependency cycles at both domain and PostgreSQL boundaries.
1 parent 9ed1c0b commit 4474a16

9 files changed

Lines changed: 503 additions & 28 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- 033 · Give canonical Program Work Items an explicit, enforceable schedule.
2+
-- A Gantt view must read planned dates; it must not infer them from creation time.
3+
4+
BEGIN;
5+
6+
ALTER TABLE design.program_work_package
7+
ADD COLUMN start_on date,
8+
ADD CONSTRAINT program_work_package_schedule_order
9+
CHECK (start_on IS NULL OR due_on IS NULL OR start_on <= due_on);
10+
11+
INSERT INTO meta.migration (filename,sha256,content_sha256,hash_source)
12+
VALUES ('033_program_work_schedule.sql','\xcb01e7590b5abf64824767f8b5cc1f0bc0cb7bf174a91785dc2421d421c852cd'::bytea,
13+
'\xcb01e7590b5abf64824767f8b5cc1f0bc0cb7bf174a91785dc2421d421c852cd'::bytea,'content')
14+
ON CONFLICT (filename) DO NOTHING;
15+
16+
COMMIT;

‎backend/kernel.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@
3737
import jobs
3838
import traces
3939

40-
DEFAULT_DSN = 'dbname=dirac user=ivan'
40+
# Deployment and test boundaries must be selectable before the kernel is built.
41+
# Keeping the fallback preserves the single-machine default; honoring DIRAC_DSN
42+
# prevents an "isolated" browser rehearsal from silently mutating the main DB.
43+
DEFAULT_DSN = os.environ.get('DIRAC_DSN', 'dbname=dirac user=ivan')
4144
DEFAULT_MOTIF_WORKER_IMAGE = (
4245
'nvcr.io/nvidia/gpu-operator@sha256:'
4346
'6584c36f153d18cfce284f7e5bc477887ce3c1ac566dc795bd80c9af6c6488f7')

‎backend/programs/domain.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,17 @@ def work_package(value: dict[str, Any]) -> dict[str, Any]:
265265
dependencies = copy.deepcopy(value.get("depends_on_refs", []))
266266
if not isinstance(deliverables, list) or not isinstance(dependencies, list):
267267
raise failures.DiracInvalidParameters("deliverable_refs and depends_on_refs must be arrays")
268+
start_on = _iso_date(value.get("start_on"), "work_package.start_on")
269+
due_on = _iso_date(value.get("due_on"), "work_package.due_on")
270+
if start_on is not None and due_on is not None and start_on > due_on:
271+
raise failures.DiracInvalidParameters(
272+
"work_package.start_on must be on or before work_package.due_on")
268273
return {"key": key(value.get("key"), "work_package.key"),
269274
"title": nonempty(value.get("title"), "work_package.title", maximum=256),
270275
"description": nonempty(value.get("description"), "work_package.description"),
271276
"lane": lane, "status": status, "priority": priority,
272277
"owner": actor(owner) if owner is not None else None,
273-
"due_on": _iso_date(value.get("due_on"), "work_package.due_on"),
278+
"start_on": start_on, "due_on": due_on,
274279
"deliverable_refs": [ref(item) for item in deliverables],
275280
"depends_on_refs": [ref(item, "work_item") for item in dependencies]}
276281

‎backend/programs/repository.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,22 @@ def record_work_package(self, program_ref: dict, expected_version: int, value: d
204204
"reason": "Created in this workflow lane", "transitioned_by": who,
205205
"transitioned_at": datetime.now(timezone.utc).isoformat()}
206206
work_item["transitions"].append(transition); row["work_transitions"].append(transition)
207+
dependency_refs = copy.deepcopy(item["depends_on_refs"])
208+
known_items = {entry["ref"]["id"]: entry for entry in row["work_items"]}
209+
if any(dependency["id"] == work_item["ref"]["id"] for dependency in dependency_refs):
210+
raise failures.DiracInvalidParameters("A Work Item cannot depend on itself")
211+
if any(dependency["id"] not in known_items for dependency in dependency_refs):
212+
raise failures.DiracInvalidParameters("Work Item dependencies must belong to the same Program")
213+
def reaches_current(candidate_id: str, visited: set[str] | None = None) -> bool:
214+
if candidate_id == work_item["ref"]["id"]: return True
215+
seen = visited or set()
216+
if candidate_id in seen: return False
217+
seen.add(candidate_id)
218+
candidate = known_items.get(candidate_id)
219+
return any(reaches_current(ref["id"], seen)
220+
for ref in (candidate or {}).get("depends_on_refs", []))
221+
if any(reaches_current(dependency["id"]) for dependency in dependency_refs):
222+
raise failures.DiracInvalidParameters("Work Item dependencies must remain acyclic")
207223
current = next((package for package in reversed(row["work_packages"])
208224
if package["work_item_ref"] == work_item["ref"]
209225
and package.get("status") != "superseded"), None)
@@ -215,6 +231,9 @@ def record_work_package(self, program_ref: dict, expected_version: int, value: d
215231
"created_by": who, "created_at": datetime.now(timezone.utc).isoformat()}
216232
row["work_packages"].append(package); work_item["title"] = package["title"]
217233
work_item["current_package"] = copy.deepcopy(package); work_item["status"] = package["status"]
234+
work_item["priority"] = package["priority"]; work_item["owner"] = copy.deepcopy(package["owner"])
235+
work_item["start_on"] = package["start_on"]; work_item["due_on"] = package["due_on"]
236+
work_item["depends_on_refs"] = dependency_refs
218237
row["version"] += 1
219238
result = {"work_item": copy.deepcopy(work_item), "work_package": copy.deepcopy(package),
220239
"program_version": row["version"], "created": created}
@@ -715,14 +734,23 @@ def record_work_package(self, program_ref: dict, expected_version: int, value: d
715734
revision = old["revision"] + 1 if old else 1
716735
if old: cur.execute("UPDATE design.program_work_package SET status='superseded' WHERE id=%s", (old["id"],))
717736
owner = item["owner"]
718-
cur.execute("INSERT INTO design.program_work_package(program_id,work_item_id,work_key,revision,title,description,status,priority,owner_kind,owner_id,due_on,deliverable_refs,supersedes_id,created_by_kind,created_by_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id,created_at",
719-
(identifier, work_row["id"], item["key"], revision, item["title"], item["description"], item["status"], item["priority"], owner["kind"] if owner else None, owner["id"] if owner else None, item["due_on"], self._json(item["deliverable_refs"]), old["id"] if old else None, who["kind"], who["id"]))
737+
cur.execute("INSERT INTO design.program_work_package(program_id,work_item_id,work_key,revision,title,description,status,priority,owner_kind,owner_id,start_on,due_on,deliverable_refs,supersedes_id,created_by_kind,created_by_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id,created_at",
738+
(identifier, work_row["id"], item["key"], revision, item["title"], item["description"], item["status"], item["priority"], owner["kind"] if owner else None, owner["id"] if owner else None, item["start_on"], item["due_on"], self._json(item["deliverable_refs"]), old["id"] if old else None, who["kind"], who["id"]))
720739
inserted = cur.fetchone()
740+
dependency_ids = [dependency["id"] for dependency in item["depends_on_refs"]]
721741
for dependency in item["depends_on_refs"]:
722742
cur.execute("SELECT program_id FROM design.program_work_item WHERE id=%s", (dependency["id"],))
723743
dependency_row = cur.fetchone()
724744
if dependency_row is None or str(dependency_row["program_id"]) != str(identifier):
725745
raise failures.DiracInvalidParameters("Work Item dependencies must belong to the same Program")
746+
if str(work_row["id"]) in dependency_ids:
747+
raise failures.DiracInvalidParameters("A Work Item cannot depend on itself")
748+
if dependency_ids:
749+
cur.execute("WITH RECURSIVE ancestors(id) AS (SELECT unnest(%s::uuid[]) UNION SELECT edge.depends_on_work_item_id FROM design.program_work_item_dependency edge JOIN ancestors ON edge.work_item_id=ancestors.id) SELECT 1 FROM ancestors WHERE id=%s LIMIT 1", (dependency_ids, work_row["id"]))
750+
if cur.fetchone():
751+
raise failures.DiracInvalidParameters("Work Item dependencies must remain acyclic")
752+
cur.execute("DELETE FROM design.program_work_item_dependency WHERE work_item_id=%s", (work_row["id"],))
753+
for dependency in item["depends_on_refs"]:
726754
cur.execute("INSERT INTO design.program_work_item_dependency(work_item_id,depends_on_work_item_id) VALUES (%s,%s) ON CONFLICT DO NOTHING",
727755
(work_row["id"], dependency["id"]))
728756
cur.execute("UPDATE design.program_work_item SET title=%s,current_package_id=%s WHERE id=%s",
@@ -1413,15 +1441,15 @@ def _overview(self, cur, identifier):
14131441
out["members"] = [self._member(r, _ref(r["principal_kind"], r["principal_id"]), r["role"]) for r in cur.fetchall()]
14141442
cur.execute("SELECT id,gate_key::text AS key,revision,stage::text,title,criteria,status,evidence_summary,decision_id,target_date,assessed_at,supersedes_id,created_at,created_by_kind::text AS actor_kind,created_by_id AS actor_id FROM design.program_stage_gate WHERE program_id=%s ORDER BY created_at DESC", (identifier,))
14151443
out["stage_gates"] = [self._stage_gate(r) for r in cur.fetchall()]
1416-
cur.execute("SELECT id,work_item_id,work_key::text AS key,revision,title,description,status,priority,owner_kind::text,owner_id,due_on,deliverable_refs,supersedes_id,created_at,created_by_kind::text AS actor_kind,created_by_id AS actor_id FROM design.program_work_package WHERE program_id=%s ORDER BY created_at DESC", (identifier,))
1444+
cur.execute("SELECT id,work_item_id,work_key::text AS key,revision,title,description,status,priority,owner_kind::text,owner_id,start_on,due_on,deliverable_refs,supersedes_id,created_at,created_by_kind::text AS actor_kind,created_by_id AS actor_id FROM design.program_work_package WHERE program_id=%s ORDER BY created_at DESC", (identifier,))
14171445
packages = cur.fetchall(); package_ids = [r["id"] for r in packages]
14181446
dependencies: dict[str, list[dict]] = {str(item): [] for item in package_ids}
14191447
if package_ids:
14201448
cur.execute("SELECT work_package_id,depends_on_id FROM design.program_work_dependency WHERE work_package_id=ANY(%s)", (package_ids,))
14211449
for dependency in cur.fetchall():
14221450
dependencies[str(dependency["work_package_id"])].append(_ref("work_package", dependency["depends_on_id"]))
14231451
out["work_packages"] = [self._work_package(r, dependencies[str(r["id"])]) for r in packages]
1424-
cur.execute("SELECT item.id,item.work_key::text AS key,item.title,item.current_lane::text AS lane,item.current_package_id,item.created_at,item.created_by_kind::text AS actor_kind,item.created_by_id AS actor_id,package.status,package.priority,package.owner_kind::text,package.owner_id,package.due_on FROM design.program_work_item item LEFT JOIN design.program_work_package package ON package.id=item.current_package_id WHERE item.program_id=%s ORDER BY item.created_at,item.work_key", (identifier,))
1452+
cur.execute("SELECT item.id,item.work_key::text AS key,item.title,item.current_lane::text AS lane,item.current_package_id,item.created_at,item.created_by_kind::text AS actor_kind,item.created_by_id AS actor_id,package.status,package.priority,package.owner_kind::text,package.owner_id,package.start_on,package.due_on FROM design.program_work_item item LEFT JOIN design.program_work_package package ON package.id=item.current_package_id WHERE item.program_id=%s ORDER BY item.created_at,item.work_key", (identifier,))
14251453
work_rows = cur.fetchall(); work_ids = [row["id"] for row in work_rows]
14261454
work_dependencies: dict[str, list[dict]] = {str(item): [] for item in work_ids}
14271455
work_transitions: dict[str, list[dict]] = {str(item): [] for item in work_ids}
@@ -1547,7 +1575,7 @@ def _work_item(row, package, dependencies, transitions, executions):
15471575
"title": row["title"], "lane": row["lane"], "status": row.get("status") or "backlog",
15481576
"priority": row.get("priority"),
15491577
"owner": _ref(row["owner_kind"], row["owner_id"]) if row.get("owner_kind") else None,
1550-
"due_on": row.get("due_on"), "current_package": package,
1578+
"start_on": row.get("start_on"), "due_on": row.get("due_on"), "current_package": package,
15511579
"depends_on_refs": dependencies, "transitions": transitions, "executions": executions,
15521580
"created_at": row["created_at"],
15531581
"created_by": {"kind": row["actor_kind"], "id": row["actor_id"]}})

‎backend/tests/test_program_aggregate.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import copy
4+
import os
5+
import subprocess
6+
import sys
47
import unittest
58
from types import SimpleNamespace
69

@@ -89,6 +92,12 @@ def test_registered_command_surface_dispatches_and_validates(self) -> None:
8992
self.assertTrue(loaded["ok"], loaded)
9093
self.assertEqual(loaded["data"]["program"]["code"], "CMD-1")
9194

95+
def test_kernel_honors_database_boundary_before_assembly(self) -> None:
96+
env = {**os.environ, "PYTHONPATH": "backend", "DIRAC_DSN": "dbname=isolated-program-test"}
97+
result = subprocess.run([sys.executable, "-c", "import kernel; print(kernel.DEFAULT_DSN)"],
98+
cwd=os.getcwd(), env=env, check=True, capture_output=True, text=True)
99+
self.assertEqual(result.stdout.strip(), "dbname=isolated-program-test")
100+
92101
def test_program_operating_system_keeps_governance_in_one_aggregate(self) -> None:
93102
portfolio = self.repo.create_portfolio({"code": "NEURO", "name": "Neuroscience"}, ACTOR)["portfolio"]
94103
assigned = self.repo.assign_portfolio(self.program_ref, 1, portfolio["ref"], ACTOR, "portfolio-1")
@@ -130,6 +139,35 @@ def test_one_work_item_moves_between_workflow_lanes_without_copying(self) -> Non
130139
self.assertEqual(overview["counts"]["work_items"], 1)
131140
self.assertEqual(overview["work_items"][0]["lane"], "design")
132141

142+
def test_work_schedule_and_dependencies_are_real_program_facts(self) -> None:
143+
first = self.repo.record_work_package(self.program_ref, 1, {
144+
"key": "map-pocket", "title": "Map pocket", "description": "Confirm the site",
145+
"lane": "understand", "status": "done", "start_on": "2026-08-14",
146+
"due_on": "2026-08-18",
147+
}, ACTOR, "schedule-first")
148+
second = self.repo.record_work_package(self.program_ref, 2, {
149+
"key": "design-series", "title": "Design series", "description": "Propose compounds",
150+
"lane": "design", "status": "active", "start_on": "2026-08-19",
151+
"due_on": "2026-08-28", "depends_on_refs": [first["work_item"]["ref"]],
152+
}, ACTOR, "schedule-second")
153+
overview = self.repo.get(self.program_ref)["program"]
154+
current = next(item for item in overview["work_items"] if item["key"] == "design-series")
155+
self.assertEqual(current["start_on"], "2026-08-19")
156+
self.assertEqual(current["due_on"], "2026-08-28")
157+
self.assertEqual(current["depends_on_refs"], [first["work_item"]["ref"]])
158+
with self.assertRaises(failures.DiracInvalidParameters):
159+
self.repo.record_work_package(self.program_ref, 3, {
160+
"key": "map-pocket", "title": "Map pocket", "description": "Create a cycle",
161+
"lane": "understand", "depends_on_refs": [second["work_item"]["ref"]],
162+
}, ACTOR, "schedule-cycle")
163+
164+
def test_work_schedule_rejects_reversed_dates(self) -> None:
165+
with self.assertRaises(failures.DiracInvalidParameters):
166+
self.repo.record_work_package(self.program_ref, 1, {
167+
"key": "time-travel", "title": "Time travel", "description": "Impossible plan",
168+
"start_on": "2026-08-20", "due_on": "2026-08-19",
169+
}, ACTOR, "bad-schedule")
170+
133171
def test_runtime_job_can_belong_to_only_one_work_item(self) -> None:
134172
first = self.repo.record_work_package(self.program_ref, 1, {
135173
"key": "first", "title": "First", "description": "First job",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import unittest
5+
import uuid
6+
7+
try:
8+
import psycopg
9+
except ImportError: # pragma: no cover
10+
psycopg = None
11+
12+
from failures import DiracInvalidParameters
13+
from programs.repository import PostgresProgramRepository
14+
15+
16+
@unittest.skipUnless(os.environ.get("DIRAC_TEST_DSN") and psycopg,
17+
"requires isolated PostgreSQL DIRAC_TEST_DSN")
18+
class PostgresProgramWorkScheduleTests(unittest.TestCase):
19+
@classmethod
20+
def setUpClass(cls) -> None:
21+
cls.connect = staticmethod(lambda: psycopg.connect(os.environ["DIRAC_TEST_DSN"]))
22+
cls.repo = PostgresProgramRepository(cls.connect)
23+
cls.actor = {"kind": "human", "id": "schedule-test"}
24+
25+
def setUp(self) -> None:
26+
suffix = uuid.uuid4().hex[:10]
27+
created = self.repo.create({"code": f"SCHEDULE-{suffix}", "name": "Schedule test"},
28+
self.actor, f"create-{suffix}")
29+
self.program_ref = created["program"]["ref"]
30+
31+
def test_schedule_round_trip_and_cycle_refusal(self) -> None:
32+
first = self.repo.record_work_package(self.program_ref, 1, {
33+
"key": "understand", "title": "Understand", "description": "Map the context",
34+
"start_on": "2026-08-13", "due_on": "2026-08-18",
35+
}, self.actor, "work-understand")
36+
second = self.repo.record_work_package(self.program_ref, 2, {
37+
"key": "design", "title": "Design", "description": "Create proposals",
38+
"lane": "design", "start_on": "2026-08-17", "due_on": "2026-08-25",
39+
"depends_on_refs": [first["work_item"]["ref"]],
40+
}, self.actor, "work-design")
41+
overview = self.repo.get(self.program_ref)["program"]
42+
design = next(item for item in overview["work_items"] if item["key"] == "design")
43+
self.assertEqual((design["start_on"], design["due_on"]),
44+
("2026-08-17", "2026-08-25"))
45+
self.assertEqual(design["depends_on_refs"], [first["work_item"]["ref"]])
46+
with self.assertRaises(DiracInvalidParameters):
47+
self.repo.record_work_package(self.program_ref, 3, {
48+
"key": "understand", "title": "Understand", "description": "Cycle",
49+
"depends_on_refs": [second["work_item"]["ref"]],
50+
}, self.actor, "cycle")
51+
self.assertEqual(self.repo.get(self.program_ref)["program"]["version"], 3)
52+
53+
def test_database_and_domain_reject_reversed_dates(self) -> None:
54+
with self.assertRaises(DiracInvalidParameters):
55+
self.repo.record_work_package(self.program_ref, 1, {
56+
"key": "reversed", "title": "Reversed", "description": "Invalid dates",
57+
"start_on": "2026-08-20", "due_on": "2026-08-19",
58+
}, self.actor, "reversed")
59+
with self.connect() as conn, conn.cursor() as cur:
60+
cur.execute("INSERT INTO design.program_work_item("
61+
"program_id,work_key,title,created_by_kind,created_by_id) "
62+
"VALUES (%s,'invalid-raw','Invalid raw item','human','test') RETURNING id",
63+
(self.program_ref["id"],))
64+
work_item_id = cur.fetchone()[0]
65+
with self.assertRaises(psycopg.errors.CheckViolation):
66+
cur.execute("INSERT INTO design.program_work_package("
67+
"program_id,work_item_id,work_key,revision,title,description,start_on,due_on,"
68+
"created_by_kind,created_by_id) VALUES (%s,%s,"
69+
"'invalid-raw',1,'Invalid','Invalid','2026-08-20','2026-08-19','human','test')",
70+
(self.program_ref["id"], work_item_id))
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

0 commit comments

Comments
 (0)