diff --git a/dag/gunbc/ci/ci_layer_roots.dag b/dag/gunbc/ci/ci_layer_roots.dag index 78617e3ebb5..4a28044af82 100644 --- a/dag/gunbc/ci/ci_layer_roots.dag +++ b/dag/gunbc/ci/ci_layer_roots.dag @@ -1005,6 +1005,11 @@ data witness_exclusion_frontier: List = [ classification: LocalRepoWetLane, reason: excl_local_repo_wet_tempdir_write_reason, dissolution: excl_local_repo_wet_dissolve}, + WitnessExclusionRow { + pattern: "roadmap_task_store_wet_witness_test.dag", + classification: LocalRepoWetLane, + reason: excl_local_repo_wet_tempdir_write_reason, + dissolution: excl_local_repo_wet_dissolve}, WitnessExclusionRow { pattern: "workspace_request_consumer_witness_test.dag", classification: LocalRepoWetLane, diff --git a/dag/gunbc/roadmap/roadmap_task_record.dag b/dag/gunbc/roadmap/roadmap_task_record.dag new file mode 100644 index 00000000000..10bdeca0799 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_task_record.dag @@ -0,0 +1,274 @@ +module gunbc.roadmap_task_record + +import std.types { String, NonEmptyStr, Bool, List, FilePath } +import gunbc.roadmap_model { RoadmapNode, RoadmapNodeId, RoadmapNodeIdentity, Pointer, WorkItemDispatch, NotDispatchable, Dispatchable, Acceptance, PrsMerged, ManualAcceptance, NodeParent, MemberOf, AdmittedRoot, Closes } +import gunbc.roadmap_status { RoadmapItem, TicketLine, TicketFields, TicketUpdate, Unsigned } +import gunbc.roadmap_execution_contract { WorkItemExecutionContract, ExecutionContractUnspecified, ExecutionContract, NotAgentWork, GunbcClaimValidation, gunbc_claims_execution_contract } +import extdeps.languages.json.emit { JsonValue, JsonNull, JsonArray, json_object, json_array, json_kv, json_string, serialize_json } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_field, json_field_string, json_field_string_list, json_field_value, json_object_unique_member, JsonMemberFound } + +// A RUNTIME TASK IS A RoadmapNode (DESIGN 3: no second task type). This module is only the wire +// form of that value: the fields a human author supplies at creation, the identity the server +// minted, and nothing derived. What the record deliberately does NOT carry: +// * signoff -- a runtime task is born Unsigned; acceptance lives on the event carrier (one +// authority, piece 6), so a record that could say "signed off" would be a second one. +// * a TicketLine's done flag -- born false for the same reason. +// * readiness -- derived by gunbc.roadmap.roadmap_readiness node_readiness from these fields. +// The execution contract is the subset an author can state in a form: unspecified, or one witness +// module with the claim functions that accept the work. A contract carrying capabilities, a path +// scope, a non-default source layout or a non-claim validation has no wire form here and encode +// refuses it rather than dropping the extra (fail closed, DESIGN 5). +// Decode is strict: wrong schema id, an unknown member kind, a missing field or an empty required +// string is Absent, never a defaulted record. + +data roadmap_task_schema_id: String = "gunbc.roadmap-task/v1" + +fn task_json_obj(value: JsonValue, key: String) -> JsonValue? { json_field_value(r: json_field(obj: value, key: key)) } +fn task_json_str(value: JsonValue, key: String) -> String? { json_field_value(r: json_field_string(obj: value, key: key)) } + +fn task_parse_acceptance(wire: String) -> Acceptance? { + if wire == "prs-merged" { Present { value: PrsMerged } } else if wire == "manual" { Present { value: ManualAcceptance } } else { none } +} + +fn task_acceptance_wire(a: Acceptance) -> String { match a { PrsMerged => "prs-merged" ManualAcceptance => "manual" } } + +fn task_pointer_json(p: Pointer) -> JsonValue { + json_object(members: [json_kv(key: "label", value: json_string(s: p.label)), json_kv(key: "path", value: json_string(s: p.path))]) +} + +fn task_pointer_decode(value: JsonValue) -> Pointer? { + match task_json_str(value: value, key: "label") { Absent => none Present { value: label } => + match task_json_str(value: value, key: "path") { Absent => none Present { value: path } => + Present { value: Pointer { label: label, path: path } } + } + } +} + +fn task_update_json(u: TicketUpdate) -> JsonValue { + json_object(members: [json_kv(key: "on", value: json_string(s: u.on)), json_kv(key: "note", value: json_string(s: u.note))]) +} + +fn task_ticket_json(t: TicketFields) -> JsonValue { + json_object(members: [ + json_kv(key: "headline", value: json_string(s: t.headline)), + json_kv(key: "brief", value: json_string(s: t.brief)), + json_kv(key: "displaced_cost", value: json_string(s: t.displaced_cost)), + json_kv(key: "current_state", value: json_string(s: t.current_state)), + json_kv(key: "first_slice", value: json_string(s: t.first_slice)), + json_kv(key: "red_control", value: json_string(s: t.red_control)), + json_kv(key: "out_of_scope", value: json_string(s: t.out_of_scope)), + json_kv(key: "handback", value: json_string(s: t.handback)), + json_kv(key: "updates", value: json_array(elements: map(t.updates, u => task_update_json(u: u)))), + json_kv(key: "authored_on", value: json_string(s: t.authored_on)), + json_kv(key: "last_verified_on", value: json_string(s: t.last_verified_on)), + ]) +} + +fn task_updates_decode(value: JsonValue) -> List? { + match json_object_unique_member(v: value, key: "updates") { + JsonMemberFound { value: JsonArray { elements: rows } } => fold(rows, init: Present { value: [] as List } as List?, f: (acc, row) => match acc { + Absent => none + Present { value: prior } => match task_json_str(value: row, key: "on") { Absent => none Present { value: on } => + match task_json_str(value: row, key: "note") { Absent => none Present { value: note } => + Present { value: concat(prior, [TicketUpdate { on: on, note: note }]) } + } + } + }) + other => none + } +} + +fn task_ticket_decode(value: JsonValue) -> TicketFields? { + match task_json_str(value: value, key: "headline") { Absent => none Present { value: headline } => + match task_json_str(value: value, key: "brief") { Absent => none Present { value: brief } => + match task_json_str(value: value, key: "displaced_cost") { Absent => none Present { value: displaced_cost } => + match task_json_str(value: value, key: "current_state") { Absent => none Present { value: current_state } => + match task_json_str(value: value, key: "first_slice") { Absent => none Present { value: first_slice } => + match task_json_str(value: value, key: "red_control") { Absent => none Present { value: red_control } => + match task_json_str(value: value, key: "out_of_scope") { Absent => none Present { value: out_of_scope } => + match task_json_str(value: value, key: "handback") { Absent => none Present { value: handback } => + match task_updates_decode(value: value) { Absent => none Present { value: updates } => + match task_json_str(value: value, key: "authored_on") { Absent => none Present { value: authored_on } => + match task_json_str(value: value, key: "last_verified_on") { Absent => none Present { value: last_verified_on } => + if headline == "" { none } else { + Present { value: TicketFields { headline: headline, brief: brief, displaced_cost: displaced_cost, current_state: current_state, first_slice: first_slice, red_control: red_control, out_of_scope: out_of_scope, handback: handback, updates: updates, authored_on: authored_on, last_verified_on: last_verified_on } } + } + } } } } } } } } } } } +} + +fn task_dispatch_json(d: WorkItemDispatch) -> JsonValue { + match d { + NotDispatchable => json_object(members: [json_kv(key: "kind", value: json_string(s: "not-dispatchable"))]) + Dispatchable { repo, acceptance } => json_object(members: [ + json_kv(key: "kind", value: json_string(s: "dispatchable")), + json_kv(key: "repo", value: json_string(s: repo)), + json_kv(key: "acceptance", value: json_string(s: task_acceptance_wire(a: acceptance))), + ]) + } +} + +fn task_dispatch_decode(value: JsonValue) -> WorkItemDispatch? { + match task_json_str(value: value, key: "kind") { + Absent => none + Present { value: "not-dispatchable" } => Present { value: NotDispatchable } + Present { value: "dispatchable" } => match task_json_str(value: value, key: "repo") { Absent => none Present { value: repo } => + match task_json_str(value: value, key: "acceptance") { Absent => none Present { value: wire } => + match task_parse_acceptance(wire: wire) { + Absent => none + Present { value: acceptance } => if repo == "" { none } else { Present { value: Dispatchable { repo: repo, acceptance: acceptance } } } + } + } + } + Present { value: other } => none + } +} + +// None means the contract has no wire form (see the module note); the caller refuses. +fn task_execution_json(c: WorkItemExecutionContract) -> JsonValue? { + match c { + ExecutionContractUnspecified => Present { value: json_object(members: [json_kv(key: "kind", value: json_string(s: "unspecified"))]) } + NotAgentWork { mode: _ } => none + ExecutionContract { validations, extra_capabilities: _, path_scope: _, source_roots: _ } => match first(validations) { + Absent => none + Present { value: GunbcClaimValidation { entry, function: _ } } => { + let functions = flat_map(validations, v => match v { GunbcClaimValidation { entry: e, function: f } => if e == entry { [f as String] } else { [] as List } other => [] as List }) + if count(functions) != count(validations) || gunbc_claims_execution_contract(entry: entry, functions: map(functions, f => f as NonEmptyStr)) != c { none } else { + Present { value: json_object(members: [ + json_kv(key: "kind", value: json_string(s: "gunbc-claims")), + json_kv(key: "entry", value: json_string(s: entry as String)), + json_kv(key: "functions", value: json_array(elements: map(functions, f => json_string(s: f)))), + ]) } + } + } + Present { value: other } => none + } + } +} + +fn task_execution_decode(value: JsonValue) -> WorkItemExecutionContract? { + match task_json_str(value: value, key: "kind") { + Absent => none + Present { value: "unspecified" } => Present { value: ExecutionContractUnspecified } + Present { value: "gunbc-claims" } => match task_json_str(value: value, key: "entry") { Absent => none Present { value: entry } => + match json_field_value(r: json_field_string_list(obj: value, key: "functions")) { Absent => none Present { value: functions } => + if entry == "" || count(functions) == 0 || any(functions, f => f == "") { none } else { + Present { value: gunbc_claims_execution_contract(entry: entry as FilePath, functions: map(functions, f => f as NonEmptyStr)) } + } + } + } + Present { value: other } => none + } +} + +// Only the two parents an author can name. Closes is minted by closing_contract_task_for and has +// no authored wire form, so a runtime record cannot forge a derived closing task's lineage. +fn task_parent_json(p: NodeParent?) -> JsonValue? { + match p { + Absent => none + Present { value: MemberOf { project, contribution } } => Present { value: json_object(members: [ + json_kv(key: "kind", value: json_string(s: "member-of")), + json_kv(key: "project", value: json_string(s: project)), + json_kv(key: "contribution", value: json_string(s: contribution as String)), + ]) } + Present { value: AdmittedRoot } => none + Present { value: Closes { target: _ } } => none + } +} + +fn task_parent_decode(value: JsonValue) -> NodeParent? { + match task_json_obj(value: value, key: "parent") { Absent => none Present { value: p } => + match task_json_str(value: p, key: "kind") { + Present { value: "member-of" } => match task_json_str(value: p, key: "project") { Absent => none Present { value: project } => + match task_json_str(value: p, key: "contribution") { Absent => none Present { value: contribution } => + if project == "" || contribution == "" { none } else { + Present { value: MemberOf { project: project, contribution: contribution as NonEmptyStr } } + } + } + } + other => none + } + } +} + +fn roadmap_task_json(node: RoadmapNode) -> JsonValue? { + match node.line { + TicketLine { done: false, ticket } => match node.signoff { + Unsigned => match task_execution_json(c: node.execution) { + Absent => none + Present { value: execution } => match task_parent_json(p: node.parent) { + Absent => none + Present { value: parent } => match node.centering { Absent => none Present { value: centering } => Present { value: json_object(members: [ + json_kv(key: "schema", value: json_string(s: roadmap_task_schema_id)), + json_kv(key: "identity", value: json_string(s: node.identity as String)), + json_kv(key: "node", value: json_string(s: node.node as String)), + json_kv(key: "ticket", value: task_ticket_json(t: ticket)), + json_kv(key: "carriers", value: json_array(elements: map(node.carriers, p => task_pointer_json(p: p)))), + json_kv(key: "dispatch", value: task_dispatch_json(d: node.dispatch)), + json_kv(key: "execution", value: execution), + json_kv(key: "owner", value: json_string(s: node.owner)), + json_kv(key: "parent", value: parent), + json_kv(key: "centering", value: json_string(s: centering as String)), + ]) } } + } + } + other => none + } + other => none + } +} + +fn task_carriers_decode(value: JsonValue) -> List? { + match json_object_unique_member(v: value, key: "carriers") { + JsonMemberFound { value: JsonArray { elements: rows } } => fold(rows, init: Present { value: [] as List } as List?, f: (acc, row) => match acc { + Absent => none + Present { value: prior } => match task_pointer_decode(value: row) { Absent => none Present { value: p } => Present { value: concat(prior, [p]) } } + }) + other => none + } +} + +fn task_centering_decode(value: JsonValue) -> NonEmptyStr? { + match task_json_str(value: value, key: "centering") { + Present { value: c } => if c == "" { none } else { Present { value: c as NonEmptyStr } } + Absent => none + } +} + +fn roadmap_task_from_json(value: JsonValue) -> RoadmapNode? { + match task_json_str(value: value, key: "schema") { + Present { value: "gunbc.roadmap-task/v1" } => { + match task_json_str(value: value, key: "identity") { Absent => none Present { value: identity } => + match task_json_str(value: value, key: "node") { Absent => none Present { value: node } => + match task_json_str(value: value, key: "owner") { Absent => none Present { value: owner } => + match task_json_obj(value: value, key: "ticket") { Absent => none Present { value: tj } => + match task_ticket_decode(value: tj) { Absent => none Present { value: ticket } => + match task_carriers_decode(value: value) { Absent => none Present { value: carriers } => + match task_json_obj(value: value, key: "dispatch") { Absent => none Present { value: dj } => + match task_dispatch_decode(value: dj) { Absent => none Present { value: dispatch } => + match task_json_obj(value: value, key: "execution") { Absent => none Present { value: ej } => + match task_execution_decode(value: ej) { Absent => none Present { value: execution } => + match task_parent_decode(value: value) { Absent => none Present { value: parent } => + match task_centering_decode(value: value) { Absent => none Present { value: centering } => + if identity == "" || node == "" || owner == "" { none } else { + Present { value: RoadmapNode { + identity: identity as RoadmapNodeIdentity, node: node as RoadmapNodeId, + line: TicketLine { done: false, ticket: ticket }, carriers: carriers, dispatch: dispatch, + execution: execution, owner: owner, signoff: Unsigned, parent: Present { value: parent }, centering: Present { value: centering }, + } } + } + } } } } } } } } } } } } + } + other => none + } +} + +fn roadmap_task_encode(node: RoadmapNode) -> String? { + match roadmap_task_json(node: node) { Absent => none Present { value: j } => Present { value: serialize_json(v: j) } } +} + +fn roadmap_task_decode(text: String) -> RoadmapNode? { + match parse_json_document(s: text) { + JsonDocumentUnreadable { gap: _ } => none + JsonDocumentParsed { value: v } => roadmap_task_from_json(value: v) + } +} diff --git a/dag/gunbc/roadmap/roadmap_task_store.dag b/dag/gunbc/roadmap/roadmap_task_store.dag new file mode 100644 index 00000000000..6b6c6f6584a --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_task_store.dag @@ -0,0 +1,128 @@ +module gunbc.roadmap_task_store + +import std.types { String, NonEmptyStr, Bool, List, EpochSecs } +import std.nat { Nat } +import std.decl_ref { decl_ref } +import std.content_hash { content_hash_of_value, serialize_content_hash } +import gunbc.fabric_storage_client { FabricStorageBinding } +import gunbc.fabric_event_log { PartitionRead, PartitionReadOk, PartitionReadRefused, EventAppend, EventAppended, EventAppendStale, EventAppendRefused, event_log_read_partition_with, event_log_append_with, event_log_read_budget } +import product.capacity.event_chain { PartitionId, ChainEvent, ChainWalked, ChainIncomplete, ChainBudgetExhausted, HeadExpectation, HeadAt, HeadAbsent, EventDecode, EventDecoded, EventUndecodable } +import product.capacity.event_json { chain_event_wire_text, chain_event_decode, member_nonempty } +import extdeps.languages.json.emit { JsonValue, json_kv, json_string } +import extdeps.languages.json.parse { json_object_unique_member, JsonMemberFound } +import gunbc.roadmap_model { RoadmapNode, RoadmapNodeId, RoadmapNodeIdentity } +import gunbc.roadmap_task_record { roadmap_task_json, roadmap_task_from_json } + +// THE RUNTIME TASK STORE. A task created at runtime is one immutable object in the fabric DB's +// authenticated state namespace, linked to the task created before it: the roster IS the chain +// (partition roadmap-tasks, gunbc.fabric_event_log), read as one closure from the head. +// +// ROSTER SHAPE, DECIDED. The wire has no listing, so something must enumerate the tasks. Two +// shapes were weighed. (1) A ledger slot: one CAS head whose value is the whole roster, one +// generation per write, never reclaimed -- every create rewrites O(n) bytes and the store keeps all +// n prefixes, quadratic in total. (2) A chain partition in the fabric_event_log shape: one object per +// task linked to its parent, one head advanced by compare-and-set on the expected head -- O(1) per +// create, the store keeps each task once, and a read is one closure. The chain is chosen, and it +// subsumes the per-task slot the brief also names: a record is IMMUTABLE once created (status and +// acceptance live on the event carrier, one authority), so a second per-task slot would be a second +// copy of a fact that never changes. This diverges from the brief on purpose. +// +// The binding is the caller's: production passes fabric_state_storage_binding(), the authenticated +// state namespace, exactly as gunbc.workspace_allocation_store does; a witness passes a local file +// root. IDEMPOTENCY: the operation key is carried in the event, the identity is minted from it, and +// a retry of the same key with the same fields is a replay answering the committed record, while +// the same key with different fields is refused. + +data roadmap_task_partition: String = "roadmap-tasks" +data roadmap_task_event_schema: String = "gunbc.roadmap-task-event/v1" + +type RuntimeTaskEvent { operation_key: NonEmptyStr, task: RoadmapNode } + +type RuntimeTaskRoster = RuntimeTaskRosterRead { head: HeadExpectation, events: List } | RuntimeTaskRosterUnreadable { reason: String } +type RuntimeTaskCreate = RuntimeTaskCreated { task: RoadmapNode } | RuntimeTaskReplayed { task: RoadmapNode } | RuntimeTaskConflict | RuntimeTaskRefused { reason: String } + +fn roadmap_task_partition_id() -> PartitionId { roadmap_task_partition as NonEmptyStr as String as PartitionId } + +// Identity and node id are minted from the operation key, never from the title or slug. +fn roadmap_task_identity_of(operation_key: NonEmptyStr) -> NonEmptyStr { + join(["task-", serialize_content_hash(hash: content_hash_of_value(value: operation_key)) as String], "") as NonEmptyStr +} + +fn roadmap_task_minted(operation_key: NonEmptyStr, draft: RoadmapNode) -> RoadmapNode { + let id = roadmap_task_identity_of(operation_key: operation_key) + RoadmapNode { identity: id as RoadmapNodeIdentity, node: id as RoadmapNodeId, line: draft.line, carriers: draft.carriers, dispatch: draft.dispatch, execution: draft.execution, owner: draft.owner, signoff: draft.signoff, parent: draft.parent, centering: draft.centering } +} + +fn roadmap_task_event_text(event: ChainEvent) -> String { + match roadmap_task_json(node: event.payload.task) { + Absent => "" + Present { value: task } => chain_event_wire_text(schema: roadmap_task_event_schema, e: event, kind: "created", members: [ + json_kv(key: "operation_key", value: json_string(s: event.payload.operation_key as String)), + json_kv(key: "task", value: task), + ]) + } +} + +fn roadmap_task_event_kind_decode(doc: JsonValue, word: String) -> RuntimeTaskEvent? { + if word != "created" { none } else { + match member_nonempty(doc: doc, key: "operation_key") { Absent => none Present { value: key } => + match json_object_unique_member(v: doc, key: "task") { + JsonMemberFound { value: t } => match roadmap_task_from_json(value: t) { + Absent => none + Present { value: task } => if (task.identity as String) == (roadmap_task_identity_of(operation_key: key) as String) { Present { value: RuntimeTaskEvent { operation_key: key, task: task } } } else { none } + } + other => none + } + } + } +} + +fn roadmap_task_event_decode(text: String) -> EventDecode { + chain_event_decode(text: text, schema: roadmap_task_event_schema, kind_decode: fn(doc, word) { roadmap_task_event_kind_decode(doc: doc, word: word) }) +} + +fn roadmap_task_roster(store: FabricStorageBinding) -> RuntimeTaskRoster { + match event_log_read_partition_with(store: store, partition: roadmap_task_partition_id(), budget: event_log_read_budget(), decode: fn(t) { roadmap_task_event_decode(text: t) }) { + PartitionReadRefused { cause: _ } => RuntimeTaskRosterUnreadable { reason: "task roster could not be read" } + PartitionReadOk { head: head, walk: walk } => match walk { + ChainWalked { oldest_first: envs } => RuntimeTaskRosterRead { head: head, events: map(envs, e => e.event.payload) } + ChainIncomplete { missing: _, newest_first_so_far: _ } => RuntimeTaskRosterUnreadable { reason: "task roster chain has a missing link" } + ChainBudgetExhausted { at: _ } => RuntimeTaskRosterUnreadable { reason: "task roster exceeds the read budget" } + } + } +} + +// The tasks, oldest first: the VALUE the plan union folds in (piece 4). +fn roadmap_tasks_listed(store: FabricStorageBinding) -> List? { + match roadmap_task_roster(store: store) { + RuntimeTaskRosterUnreadable { reason: _ } => none + RuntimeTaskRosterRead { head: _, events: events } => Present { value: map(events, e => e.task) } + } +} + +fn roadmap_task_create(store: FabricStorageBinding, actor: NonEmptyStr, at: EpochSecs, operation_key: NonEmptyStr, draft: RoadmapNode) -> RuntimeTaskCreate { + let task = roadmap_task_minted(operation_key: operation_key, draft: draft) + match roadmap_task_json(node: task) { + Absent => RuntimeTaskRefused { reason: "task has no wire form" } + Present { value: _ } => match roadmap_task_roster(store: store) { + RuntimeTaskRosterUnreadable { reason: reason } => RuntimeTaskRefused { reason: reason } + RuntimeTaskRosterRead { head: head, events: events } => match filter(events, e => (e.operation_key as String) == (operation_key as String)).first() { + Present { value: prior } => if roadmap_task_event_text(event: ChainEvent { partition: roadmap_task_partition_id(), parent: none, recorded_at: 0, actor: actor, payload: prior }) == roadmap_task_event_text(event: ChainEvent { partition: roadmap_task_partition_id(), parent: none, recorded_at: 0, actor: actor, payload: RuntimeTaskEvent { operation_key: operation_key, task: task } }) { + RuntimeTaskReplayed { task: prior.task } + } else { RuntimeTaskRefused { reason: "operation key already names a different task" } } + Absent => roadmap_task_append(store: store, head: head, actor: actor, at: at, event: RuntimeTaskEvent { operation_key: operation_key, task: task }) + } + } + } +} + +fn roadmap_task_append(store: FabricStorageBinding, head: HeadExpectation, actor: NonEmptyStr, at: EpochSecs, event: RuntimeTaskEvent) -> RuntimeTaskCreate + admit_callers: [decl_ref(module_path: "gunbc.roadmap_task_store", decl_name: "roadmap_task_create")] +{ + let parent = match head { HeadAt { id: i } => Present { value: i } HeadAbsent => none } + match event_log_append_with(store: store, partition: roadmap_task_partition_id(), event: ChainEvent { partition: roadmap_task_partition_id(), parent: parent, recorded_at: at, actor: actor, payload: event }, expected: head, encode: fn(e) { roadmap_task_event_text(event: e) }) { + EventAppended { id: _ } => RuntimeTaskCreated { task: event.task } + EventAppendStale { expected: _, observed: _ } => RuntimeTaskConflict + EventAppendRefused { cause: _ } => RuntimeTaskRefused { reason: "task publication refused" } + } +} diff --git a/dag/test/claim/roadmap/roadmap_task_record_witness_test.dag b/dag/test/claim/roadmap/roadmap_task_record_witness_test.dag new file mode 100644 index 00000000000..c72f52abf68 --- /dev/null +++ b/dag/test/claim/roadmap/roadmap_task_record_witness_test.dag @@ -0,0 +1,57 @@ +module test.claim.roadmap_task_record_witness_test + +import std.types { Bool, String, NonEmptyStr, FilePath, List } +import gunbc.roadmap_model { RoadmapNode, RoadmapNodeId, RoadmapNodeIdentity, Pointer, Dispatchable, PrsMerged, MemberOf } +import gunbc.roadmap_status { TicketLine, TicketFields, TicketUpdate, Unsigned } +import gunbc.roadmap_execution_contract { ExecutionContractUnspecified, gunbc_claims_execution_contract } +import gunbc.roadmap_task_record { roadmap_task_encode, roadmap_task_decode } + +fn sample_ticket() -> TicketFields { + TicketFields { headline: "Create tasks at runtime", brief: "b", displaced_cost: "d", current_state: "c", first_slice: "f", red_control: "r", out_of_scope: "o", handback: "h", + updates: [TicketUpdate { on: "2026-10-09", note: "n" }], authored_on: "2026-10-09", last_verified_on: "" } +} + +fn sample_task() -> RoadmapNode { + RoadmapNode { + identity: "task-0001" as RoadmapNodeIdentity, node: "runtime-task-one" as RoadmapNodeId, + line: TicketLine { done: false, ticket: sample_ticket() }, + carriers: [Pointer { label: "plan", path: "docs/plans/x.md" }], + dispatch: Dispatchable { repo: "gunbc", acceptance: PrsMerged }, + execution: gunbc_claims_execution_contract(entry: "dag/test/claim/x_test.dag" as FilePath, functions: ["a" as NonEmptyStr, "b" as NonEmptyStr]), + owner: "briansrls", signoff: Unsigned, + parent: Present { value: MemberOf { project: "roadmap-runtime", contribution: "creates tasks" as NonEmptyStr } }, + centering: Present { value: "daily workspace" as NonEmptyStr }, + } +} + +// Round trip is exact: decode(encode(task)) re-encodes to the same bytes. +test fn task_record_round_trips() -> Bool { + match roadmap_task_encode(node: sample_task()) { + Absent => false + Present { value: text } => match roadmap_task_decode(text: text) { + Absent => false + Present { value: back } => match roadmap_task_encode(node: back) { Absent => false Present { value: again } => again == text } + } + } +} + +// Strict decode: a wrong schema id and a missing identity are refused, not defaulted. +test fn task_record_decode_refuses_wrong_schema_and_missing_identity() -> Bool { + match roadmap_task_encode(node: sample_task()) { + Absent => false + Present { value: text } => { + let wrong_schema = replace(text, "gunbc.roadmap-task/v1", "gunbc.roadmap-task/v2") + let no_identity = replace(text, "\"identity\":", "\"identit\":") + let none_a = match roadmap_task_decode(text: wrong_schema) { Absent => true Present { value: _ } => false } + let none_b = match roadmap_task_decode(text: no_identity) { Absent => true Present { value: _ } => false } + none_a && none_b + } + } +} + +// A contract with no wire form is refused at encode, never silently narrowed. +test fn task_record_encode_refuses_signed_task() -> Bool { + let base = sample_task() + let signed = RoadmapNode { identity: base.identity, node: base.node, line: TicketLine { done: true, ticket: sample_ticket() }, carriers: base.carriers, dispatch: base.dispatch, execution: ExecutionContractUnspecified, owner: base.owner, signoff: Unsigned, parent: base.parent, centering: base.centering } + match roadmap_task_encode(node: signed) { Absent => true Present { value: _ } => false } +} diff --git a/dag/test/claim/roadmap/roadmap_task_store_wet_witness_test.dag b/dag/test/claim/roadmap/roadmap_task_store_wet_witness_test.dag new file mode 100644 index 00000000000..d061c9eac3d --- /dev/null +++ b/dag/test/claim/roadmap/roadmap_task_store_wet_witness_test.dag @@ -0,0 +1,44 @@ +module test.claim.roadmap_task_store_wet_witness_test + +import std.types { Bool, String, NonEmptyStr, List } +import extdeps.shell +import gunbc.fabric_storage_client { FabricStorageLocalFiles } +import gunbc.fabric_storage_file_store { fabric_storage_file_root, fabric_storage_file_root_ensure, FabricStorageRootReady } +import gunbc.roadmap_model { RoadmapNode } +import gunbc.roadmap_status { TicketLine } +import gunbc.roadmap_task_store { roadmap_task_create, roadmap_tasks_listed, RuntimeTaskCreated, RuntimeTaskReplayed, RuntimeTaskConflict, RuntimeTaskRefused } +import test.claim.roadmap_task_record_witness_test { sample_task, sample_ticket } + +fn titled(n: RoadmapNode, headline: String) -> RoadmapNode { + RoadmapNode { identity: n.identity, node: n.node, line: TicketLine { done: false, ticket: gunbc.roadmap_status.TicketFields { headline: headline, brief: "", displaced_cost: "", current_state: "", first_slice: "", red_control: "", out_of_scope: "", handback: "", updates: [], authored_on: "", last_verified_on: "" } }, carriers: n.carriers, dispatch: n.dispatch, execution: n.execution, owner: n.owner, signoff: n.signoff, parent: n.parent, centering: n.centering } +} + +// THE WRITE LANDS against a real file store: create commits, the same key replays without a new +// link, a second key extends the roster in order, and the same key with different fields refuses. +test fn task_store_create_replay_and_conflicting_key() -> Bool { + let directory = shell.Mktemp.Dir() + let root = fabric_storage_file_root(root: directory.path) + let store = FabricStorageLocalFiles { root: root } + if !directory.success { false } else { match fabric_storage_file_root_ensure(root: root) { + FabricStorageRootReady => { + let actor = "briansrls" as NonEmptyStr + let k1 = "op-1" as NonEmptyStr + let k2 = "op-2" as NonEmptyStr + let first = roadmap_task_create(store: store, actor: actor, at: 1, operation_key: k1, draft: sample_task()) + let replay = roadmap_task_create(store: store, actor: actor, at: 2, operation_key: k1, draft: sample_task()) + let conflicting = roadmap_task_create(store: store, actor: actor, at: 3, operation_key: k1, draft: titled(n: sample_task(), headline: "Different")) + let second = roadmap_task_create(store: store, actor: actor, at: 4, operation_key: k2, draft: titled(n: sample_task(), headline: "Second")) + let listed = roadmap_tasks_listed(store: store) + let ok_first = match first { RuntimeTaskCreated { task: _ } => true other => false } + let ok_replay = match replay { RuntimeTaskReplayed { task: _ } => true other => false } + let ok_conflict = match conflicting { RuntimeTaskRefused { reason: _ } => true other => false } + let ok_second = match second { RuntimeTaskCreated { task: _ } => true other => false } + let ok_listed = match listed { Present { value: ts } => count(ts) == 2 && (first_identity(ts: ts) != second_identity(ts: ts)) Absent => false } + ok_first && ok_replay && ok_conflict && ok_second && ok_listed + } + other => false + } } +} + +fn first_identity(ts: List) -> String { match first(ts) { Present { value: t } => t.identity as String Absent => "" } } +fn second_identity(ts: List) -> String { match first(reverse(ts)) { Present { value: t } => t.identity as String Absent => "" } } diff --git a/src/v2/workflow/floor_route_gap.dag b/src/v2/workflow/floor_route_gap.dag index dbd2cd7655a..6419bff02d2 100644 --- a/src/v2/workflow/floor_route_gap.dag +++ b/src/v2/workflow/floor_route_gap.dag @@ -2259,6 +2259,8 @@ fn floor_route_gap_expectation_chunk_35() -> List { tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.workspace_request_consumer_witness_test.enrolled_roster_discovers_a_customer_request_the_operator_pass_misses", operation: "Dir", ground: NoMockResponse {} }, tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.roadmap_task_store_wet_witness_test.task_store_create_replay_and_conflicting_key", operation: "Dir", ground: NoMockResponse {} }, + tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.workspace_allocation_write_seam_wet_witness_test.seam_admitted_create_and_release_land_in_the_store", operation: "Dir", ground: NoMockResponse {} }, tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.live_deploy.tailnet_door_cutover_journal_wet_witness_test.the_cutover_journal_survives_its_writer_and_fences_a_stale_write", operation: "Dir", ground: NoMockResponse {} }, @@ -2268,7 +2270,7 @@ fn floor_route_gap_expectation_chunk_35() -> List { head: FloorRouteGapExpectation { identity: "gunbc.test.claim.workspace_acquisition_storage_wet_witness_test.running_elected_actor_retains_quota", operation: "Dir", ground: NoMockResponse {} }, tail: Cons { head: FloorRouteGapExpectation { identity: "gunbc.test.claim.workspace_acquisition_storage_wet_witness_test.concurrent_actor_replacement_admits_exactly_one", operation: "Dir", ground: NoMockResponse {} }, - tail: Empty {} } } } } } } } } } } } } } } + tail: Empty {} } } } } } } } } } } } } } } } } // THE PROCESS-LIFECYCLE WET EVIDENCE (the emit-host node serve smoke), the third member of its diff --git a/src/v2/workflow/local_repo_wet_terminal.dag b/src/v2/workflow/local_repo_wet_terminal.dag index 536251cec66..3cfc9869f7b 100644 --- a/src/v2/workflow/local_repo_wet_terminal.dag +++ b/src/v2/workflow/local_repo_wet_terminal.dag @@ -2943,6 +2943,12 @@ fn local_repo_wet_schedule() -> List { function: "waiting_reason_is_durable_but_cannot_refresh_readiness_or_overwrite_retirement", expectation: ExpectedToHold {} }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.roadmap_task_store_wet_witness_test", function: "task_store_create_replay_and_conflicting_key" }, + entry: "dag/test/claim/roadmap/roadmap_task_store_wet_witness_test.dag", + function: "task_store_create_replay_and_conflicting_key", + expectation: ExpectedToHold {} + }, WetScheduledClaim { identity: WitnessIdentity { module_path: "test.claim.workspace_allocation_write_seam_wet_witness_test", function: "seam_admitted_create_and_release_land_in_the_store" }, entry: "dag/test/claim/roadmap/workspace_allocation_write_seam_wet_witness_test.dag",