okstra 0.214.1 → 0.216.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/README.md +2 -1
- package/dist/cli-registry.d.mts +14 -0
- package/dist/cli-registry.mjs +12 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +2 -56
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -2
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +57 -6
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/file-lock.d.mts +1 -0
- package/dist/lib/file-lock.mjs +60 -0
- package/dist/lib/file-lock.mjs.map +1 -0
- package/dist/lib/python-command.mjs +11 -1
- package/dist/lib/python-command.mjs.map +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/dist/lib/types.d.mts +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +36 -14
- package/docs/project-structure-overview.md +6 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +9 -2
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/interactive.sh +8 -2
- package/runtime/bin/lib/okstra/usage.sh +3 -1
- package/runtime/bin/okstra.sh +7 -1
- package/runtime/prompts/duties/discussion-worker.json +32 -0
- package/runtime/prompts/lead/convergence.md +58 -136
- package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +14 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +8 -2
- package/runtime/python/okstra_ctl/convergence.py +143 -29
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
- package/runtime/python/okstra_ctl/convergence_store.py +11 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/manager_cli.py +27 -1
- package/runtime/python/okstra_ctl/manager_launch.py +44 -4
- package/runtime/python/okstra_ctl/manager_sync.py +14 -1
- package/runtime/python/okstra_ctl/manager_view.py +53 -1
- package/runtime/python/okstra_ctl/next_phase.py +1 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
- package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
- package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
- package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
- package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
- package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
- package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
- package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
- package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
- package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
- package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
- package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
- package/runtime/python/okstra_ctl/render.py +7 -20
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/report_assembly.py +22 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_narrative.py +13 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
- package/runtime/python/okstra_ctl/run.py +40 -7
- package/runtime/python/okstra_ctl/stage_close.py +46 -11
- package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
- package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
- package/runtime/python/okstra_ctl/wizard/render.py +4 -1
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
- package/runtime/python/okstra_ctl/wizard/state.py +11 -1
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
- package/runtime/python/okstra_token_usage/pricing.py +2 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +90 -1
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
- package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
- package/runtime/skills/_fragments/wizard-preflight.md +75 -0
- package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
- package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
- package/runtime/skills/okstra-discussion/SKILL.md +347 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
- package/runtime/skills/okstra-run/SKILL.md +22 -2
- package/runtime/templates/manager/view.template.html +3 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -0
- package/runtime/templates/reports/html/i18n/ko.json +5 -0
- package/runtime/validators/validate-brief.py +3 -2
- package/runtime/validators/validate-run.py +4 -6
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""토론의 조사·반박·질문 순환 — 주기 열기, 결과 정산, 주기 종료, 직접 질문, 실패 복구.
|
|
2
|
+
|
|
3
|
+
워커 실행은 공통 배정·디스패치 경로(`agent-prompt materialize` 다음 `worker-dispatch`
|
|
4
|
+
또는 `team dispatch`)가, 근거 판정은 수렴 엔진(`convergence_engine`)이 맡는다. 여기는 그 앞뒤에서 토론 기록을 읽고 쓰는 일만 한다.
|
|
5
|
+
|
|
6
|
+
조사 주기 하나는 run 하나다. 수렴 상태·그룹 경로가 run 마다 하나로 고정되어 있어
|
|
7
|
+
(`paths.py` 의 `convergence-<task-type>-<seq>.json`) 한 run 안에서 두 번째 수렴을
|
|
8
|
+
열 자리가 없다. 다음 주기와 참여자 교체는 `--discussion-start <task-key>` 로 준비한
|
|
9
|
+
이어가기 run 에서 연다.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Mapping
|
|
17
|
+
|
|
18
|
+
from okstra_ctl.convergence_engine import validate_final_state
|
|
19
|
+
from okstra_ctl.dispatch_core import CLI_BATCH_LAUNCH_FAILURE
|
|
20
|
+
from okstra_ctl.dispatch_state import dispatch_worker_key, worker_dispatch_records
|
|
21
|
+
from okstra_ctl.execution_manifest import read_execution_manifest
|
|
22
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
23
|
+
from okstra_ctl.report_finalize import run_seq
|
|
24
|
+
from okstra_ctl.worker_prompt_policy import (
|
|
25
|
+
ANALYSIS_DUTY_BY_TASK_TYPE,
|
|
26
|
+
DISCUSSION_QUESTION_DISPATCH_KIND,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .store import (
|
|
30
|
+
LEADER,
|
|
31
|
+
USER,
|
|
32
|
+
DiscussionStore,
|
|
33
|
+
DiscussionStoreError,
|
|
34
|
+
SourceRun,
|
|
35
|
+
read_draft,
|
|
36
|
+
render_draft_text,
|
|
37
|
+
resolve_source_run,
|
|
38
|
+
same_task,
|
|
39
|
+
transaction,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
RESEARCH_DISPATCH_KIND = "initial"
|
|
43
|
+
CHOICES = ("retry", "replace", "reduce")
|
|
44
|
+
_QUESTION_CHOICES = ("retry", "reduce")
|
|
45
|
+
_DUTY = ANALYSIS_DUTY_BY_TASK_TYPE["discussion"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DiscussionWorkflowError(DiscussionStoreError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DiscussionRun:
|
|
54
|
+
source: SourceRun
|
|
55
|
+
project_root: Path
|
|
56
|
+
manifest: Mapping[str, Any]
|
|
57
|
+
seq: str
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def run_dir(self) -> Path:
|
|
61
|
+
return self.project_root / str(self.manifest["runDirectoryPath"])
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def analysers(self) -> tuple[dict[str, str], ...]:
|
|
65
|
+
return tuple(
|
|
66
|
+
{"worker": str(row["workerId"]), "model": f"{row['provider']}/{row['model']}"}
|
|
67
|
+
for row in self.manifest.get("workerAssignments") or []
|
|
68
|
+
if isinstance(row, dict) and row.get("role") == "analyser"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def project_path(self, value: str) -> Path:
|
|
72
|
+
path = Path(value)
|
|
73
|
+
return path if path.is_absolute() else self.project_root / path
|
|
74
|
+
|
|
75
|
+
def relative(self, path: Path) -> str:
|
|
76
|
+
try:
|
|
77
|
+
return path.resolve().relative_to(self.project_root.resolve()).as_posix()
|
|
78
|
+
except ValueError:
|
|
79
|
+
# expected-miss: 프로젝트 밖 경로는 받은 그대로 기록한다.
|
|
80
|
+
return str(path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_run(store: DiscussionStore, manifest_path: str) -> DiscussionRun:
|
|
84
|
+
source = resolve_source_run(store, manifest_path)
|
|
85
|
+
path = store.project_root / source.manifest
|
|
86
|
+
try:
|
|
87
|
+
manifest = load_owned_object(path, artifact="discussion run manifest")
|
|
88
|
+
except JsonBoundaryError as exc:
|
|
89
|
+
raise DiscussionWorkflowError(str(exc)) from exc
|
|
90
|
+
return DiscussionRun(source, store.project_root, manifest, run_seq(manifest))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _digest(data: bytes) -> str:
|
|
94
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cycle_of_run(draft: Mapping[str, Any], run: DiscussionRun) -> dict[str, Any] | None:
|
|
98
|
+
mine = [cycle for cycle in draft["cycles"] if cycle["sourceRun"] == run.source.manifest]
|
|
99
|
+
return mine[-1] if mine else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _last_closed(draft: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
103
|
+
closed = [cycle for cycle in draft["cycles"] if cycle["status"] == "closed"]
|
|
104
|
+
return closed[-1] if closed else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _claims(draft: Mapping[str, Any], ids: list[str]) -> list[dict[str, Any]]:
|
|
108
|
+
by_id = {claim["recordId"]: claim for cycle in draft["cycles"] for claim in cycle["claims"]}
|
|
109
|
+
return [
|
|
110
|
+
{key: by_id[claim_id][key] for key in ("recordId", "standing", "classification", "summary", "author")}
|
|
111
|
+
for claim_id in ids
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _present_options(draft: Mapping[str, Any], cycle: Mapping[str, Any]) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"action": "present-options",
|
|
118
|
+
"reason": (
|
|
119
|
+
f"the research input references equal those of {cycle['recordId']}; no new "
|
|
120
|
+
"evidence, user answer or alternative arrived, so research is not dispatched again"
|
|
121
|
+
),
|
|
122
|
+
"remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
|
|
123
|
+
"alternatives": [
|
|
124
|
+
{"recordId": row["recordId"], "text": row["text"], "author": row["author"]}
|
|
125
|
+
for row in draft["materials"] if row["materialKind"] == "alternative"
|
|
126
|
+
],
|
|
127
|
+
"userOptions": [
|
|
128
|
+
"choose a direction and record it with `okstra discussion decide`",
|
|
129
|
+
"bring new evidence or an alternative with `okstra discussion add-material`, then research again",
|
|
130
|
+
"ask one participant directly with `okstra discussion address`",
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _waiting(draft: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
136
|
+
if draft["unrecoveredFailures"]:
|
|
137
|
+
return {"action": "await-recovery", "failures": draft["unrecoveredFailures"]}
|
|
138
|
+
if draft["pendingForUser"]:
|
|
139
|
+
return {"action": "await-user", "nextForUser": draft["nextForUser"]}
|
|
140
|
+
if draft["pendingForParticipants"]:
|
|
141
|
+
return {"action": "await-participant", "questions": draft["pendingForParticipants"]}
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _research_instruction(cycle_id: str, draft: Mapping[str, Any]) -> str:
|
|
146
|
+
refs = ", ".join(draft["inputRefs"]) or "none"
|
|
147
|
+
return (
|
|
148
|
+
f"# Discussion research — {cycle_id}\n\n"
|
|
149
|
+
"Research the confirmed subject and question of the discussion record below.\n\n"
|
|
150
|
+
"- Read the whole record first: every user answer, every participant answer and "
|
|
151
|
+
"every user decision. A user decision stands; do not argue it away with a fact check.\n"
|
|
152
|
+
"- Report each factual claim about the code or an external source as a numbered "
|
|
153
|
+
"finding with its evidence location, and say what the evidence could not reach.\n"
|
|
154
|
+
"- For each alternative direction, state what it requires, what it excludes and the "
|
|
155
|
+
"risk it carries, without ranking it into a decision.\n"
|
|
156
|
+
"- List the questions only the user can answer (intent, scope, priority, acceptable "
|
|
157
|
+
"risk) under the heading `Questions for the user`; do not report them as findings.\n"
|
|
158
|
+
"- A claim already in the record is new only with new evidence: name the record it "
|
|
159
|
+
"extends or refutes.\n\n"
|
|
160
|
+
f"Input references of this cycle: {refs}\n\n"
|
|
161
|
+
+ render_draft_text(draft)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def open_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
|
|
166
|
+
with transaction(store) as txn:
|
|
167
|
+
draft = txn.draft()
|
|
168
|
+
if draft["subject"] is None:
|
|
169
|
+
raise DiscussionWorkflowError(
|
|
170
|
+
"confirm the subject and the question with the user first (`okstra discussion confirm-subject`)"
|
|
171
|
+
)
|
|
172
|
+
if draft["completedRevision"] is not None:
|
|
173
|
+
return {
|
|
174
|
+
"action": "completed",
|
|
175
|
+
"revisionId": draft["completedRevision"],
|
|
176
|
+
"reason": "the user completed the discussion; `okstra discussion reopen` opens a new draft",
|
|
177
|
+
}
|
|
178
|
+
if draft["unrecoveredFailures"]:
|
|
179
|
+
return _waiting(draft)
|
|
180
|
+
mine = _cycle_of_run(draft, run)
|
|
181
|
+
if mine is not None:
|
|
182
|
+
return _cycle_of_this_run(store, run, draft, mine)
|
|
183
|
+
waiting = _waiting(draft)
|
|
184
|
+
if waiting is not None:
|
|
185
|
+
return waiting
|
|
186
|
+
closed = _last_closed(draft)
|
|
187
|
+
if closed is not None and closed["inputRefs"] == draft["inputRefs"]:
|
|
188
|
+
return _present_options(draft, closed)
|
|
189
|
+
cycle_id = f"R-{1 + len(draft['cycles']):04d}"
|
|
190
|
+
text = _research_instruction(cycle_id, draft)
|
|
191
|
+
path = run.run_dir / "prompts" / f"discussion-research-discussion-{run.seq}.md"
|
|
192
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
path.write_text(text, encoding="utf-8")
|
|
194
|
+
record, created = txn.append(
|
|
195
|
+
"cycle",
|
|
196
|
+
{
|
|
197
|
+
"inputRefs": draft["inputRefs"],
|
|
198
|
+
"roster": list(run.analysers),
|
|
199
|
+
"instructionPath": run.relative(path),
|
|
200
|
+
"instructionDigest": _digest(text.encode("utf-8")),
|
|
201
|
+
},
|
|
202
|
+
author=LEADER,
|
|
203
|
+
source_run=run.source,
|
|
204
|
+
)
|
|
205
|
+
return _dispatch_payload(run, record, created)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _cycle_of_this_run(
|
|
209
|
+
store: DiscussionStore, run: DiscussionRun, draft: Mapping[str, Any], cycle: Mapping[str, Any],
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
if cycle["status"] == "open":
|
|
212
|
+
path = run.project_path(cycle["instructionPath"])
|
|
213
|
+
if not path.is_file() or _digest(path.read_bytes()) != cycle["instructionDigest"]:
|
|
214
|
+
raise DiscussionWorkflowError(
|
|
215
|
+
f"the research instruction of {cycle['recordId']} ({cycle['instructionPath']}) is "
|
|
216
|
+
"missing or changed since the cycle opened; it is the input the cycle recorded"
|
|
217
|
+
)
|
|
218
|
+
return _dispatch_payload(run, cycle, False)
|
|
219
|
+
if cycle["status"] == "closed" and draft["inputRefs"] == cycle["inputRefs"]:
|
|
220
|
+
return _present_options(draft, cycle)
|
|
221
|
+
reason = (
|
|
222
|
+
"the user chose to replace a participant"
|
|
223
|
+
if cycle["status"] == "superseded"
|
|
224
|
+
else f"new input arrived after {cycle['recordId']} closed"
|
|
225
|
+
)
|
|
226
|
+
return {
|
|
227
|
+
"action": "prepare-run",
|
|
228
|
+
"reason": f"{reason}; one run holds one research cycle",
|
|
229
|
+
"discussionStart": store.task_key,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _dispatch_payload(run: DiscussionRun, cycle: Mapping[str, Any], created: bool) -> dict[str, Any]:
|
|
234
|
+
return {
|
|
235
|
+
"action": "dispatch",
|
|
236
|
+
"created": created,
|
|
237
|
+
"cycle": cycle["recordId"],
|
|
238
|
+
"inputRefs": cycle["inputRefs"],
|
|
239
|
+
"instructionPath": cycle["instructionPath"],
|
|
240
|
+
"dispatchKind": RESEARCH_DISPATCH_KIND,
|
|
241
|
+
"audience": _DUTY,
|
|
242
|
+
"workers": [row["worker"] for row in run.analysers],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _team_state(run: DiscussionRun) -> Mapping[str, Any]:
|
|
247
|
+
try:
|
|
248
|
+
return load_owned_object(run.project_path(str(run.manifest["teamStatePath"])), artifact="team state")
|
|
249
|
+
except JsonBoundaryError as exc:
|
|
250
|
+
raise DiscussionWorkflowError(str(exc)) from exc
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _final_rows(rows: list[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
|
|
254
|
+
"""invocation 마다 마지막 시도 행 — 앞 시도의 실패는 기존 재시도 루프 안의 일이다."""
|
|
255
|
+
latest: dict[str, Mapping[str, Any]] = {}
|
|
256
|
+
for row in rows:
|
|
257
|
+
latest[str(row.get("invocationId") or row.get("promptPath"))] = row
|
|
258
|
+
return list(latest.values())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _cli_outcome(run: DiscussionRun, row: Mapping[str, Any]) -> dict[str, Any]:
|
|
262
|
+
status = str(row.get("status") or "")
|
|
263
|
+
outcome = {
|
|
264
|
+
"label": str(row.get("invocationId") or row.get("promptPath")),
|
|
265
|
+
"invocationId": str(row.get("invocationId") or ""),
|
|
266
|
+
"dispatch": str(row.get("dispatchId") or f"{row.get('promptPath')}#{row.get('attempt')}"),
|
|
267
|
+
"attempts": int(row.get("attempt") or 1),
|
|
268
|
+
}
|
|
269
|
+
if status == "running":
|
|
270
|
+
return {**outcome, "state": "running"}
|
|
271
|
+
if status == "completed":
|
|
272
|
+
result = str(row.get("workerResultPath") or row.get("resultPath"))
|
|
273
|
+
return {**outcome, "state": "completed", "resultPath": run.relative(run.project_path(result))}
|
|
274
|
+
reason = str(row.get("reason") or status)
|
|
275
|
+
return {
|
|
276
|
+
**outcome,
|
|
277
|
+
"state": "failed",
|
|
278
|
+
"stage": "start" if reason.startswith(CLI_BATCH_LAUNCH_FAILURE) else "after-retries",
|
|
279
|
+
"status": status,
|
|
280
|
+
"reason": reason,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _native_outcomes(
|
|
285
|
+
run: DiscussionRun, team_state: Mapping[str, Any], worker: str, kind: str,
|
|
286
|
+
) -> list[dict[str, Any]]:
|
|
287
|
+
"""호스트 네이티브 세션 디스패치 — 결과는 링크로, 결과 없는 종결은 시도 원장으로 안다."""
|
|
288
|
+
rows = [
|
|
289
|
+
row for row in team_state.get("agentDispatches") or []
|
|
290
|
+
if isinstance(row, Mapping) and row.get("dispatchKind") == kind
|
|
291
|
+
and dispatch_worker_key(row) == worker
|
|
292
|
+
]
|
|
293
|
+
if not rows:
|
|
294
|
+
return []
|
|
295
|
+
links = {
|
|
296
|
+
link["dispatchId"]: link for link in team_state.get("agentResultLinks") or []
|
|
297
|
+
if isinstance(link, Mapping) and not link.get("supersededBy")
|
|
298
|
+
}
|
|
299
|
+
attempts = {
|
|
300
|
+
(attempt.invocation_ref, attempt.attempt): attempt
|
|
301
|
+
for attempt in read_execution_manifest(run.project_root / run.source.manifest).attempts
|
|
302
|
+
}
|
|
303
|
+
outcomes = []
|
|
304
|
+
for row in _final_rows(rows):
|
|
305
|
+
outcome = {
|
|
306
|
+
"label": str(row["invocationId"]),
|
|
307
|
+
"invocationId": str(row["invocationId"]),
|
|
308
|
+
"dispatch": str(row["dispatchId"]),
|
|
309
|
+
"attempts": int(row.get("attempt") or 1),
|
|
310
|
+
}
|
|
311
|
+
link = links.get(row["dispatchId"])
|
|
312
|
+
attempt = attempts.get((row.get("invocationRef"), row.get("attempt")))
|
|
313
|
+
if link is not None:
|
|
314
|
+
outcomes.append({**outcome, "state": "completed", "resultPath": str(link["resultPath"])})
|
|
315
|
+
elif attempt is None or attempt.finished_at is None:
|
|
316
|
+
outcomes.append({**outcome, "state": "running"})
|
|
317
|
+
else:
|
|
318
|
+
outcomes.append({
|
|
319
|
+
**outcome, "state": "failed", "stage": "after-retries", "status": attempt.status,
|
|
320
|
+
"reason": f"the attempt closed as {attempt.status} without a linked result",
|
|
321
|
+
})
|
|
322
|
+
return outcomes
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _outcomes(
|
|
326
|
+
run: DiscussionRun, team_state: Mapping[str, Any], worker: str, kind: str,
|
|
327
|
+
) -> list[dict[str, Any]]:
|
|
328
|
+
cli_rows = [row for row in worker_dispatch_records(team_state, worker) if row.get("kind") == kind]
|
|
329
|
+
outcomes = [_cli_outcome(run, row) for row in _final_rows(cli_rows)]
|
|
330
|
+
outcomes += _native_outcomes(run, team_state, worker, kind)
|
|
331
|
+
running = [outcome["label"] for outcome in outcomes if outcome["state"] == "running"]
|
|
332
|
+
if running:
|
|
333
|
+
raise DiscussionWorkflowError(
|
|
334
|
+
f"{', '.join(running)} of {worker} is still running; wait for it "
|
|
335
|
+
"(`okstra team await` or the host's own completion) before settling"
|
|
336
|
+
)
|
|
337
|
+
return outcomes
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _settled_fields(outcome: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
341
|
+
if outcome["state"] == "completed":
|
|
342
|
+
return "result", {"dispatch": outcome["dispatch"], "resultPath": outcome["resultPath"]}
|
|
343
|
+
return "failure", {
|
|
344
|
+
key: outcome[key] for key in ("dispatch", "stage", "attempts", "status", "reason")
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def settle_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
|
|
349
|
+
team_state = _team_state(run)
|
|
350
|
+
with transaction(store) as txn:
|
|
351
|
+
cycle = _cycle_of_run(txn.draft(), run)
|
|
352
|
+
if cycle is None or cycle["status"] != "open":
|
|
353
|
+
raise DiscussionWorkflowError(
|
|
354
|
+
"this run has no open research cycle; run `okstra discussion cycle-open` first"
|
|
355
|
+
)
|
|
356
|
+
not_dispatched = []
|
|
357
|
+
for analyser in run.analysers:
|
|
358
|
+
worker = analyser["worker"]
|
|
359
|
+
outcomes = _outcomes(run, team_state, worker, RESEARCH_DISPATCH_KIND)
|
|
360
|
+
if not outcomes:
|
|
361
|
+
not_dispatched.append(worker)
|
|
362
|
+
for outcome in outcomes:
|
|
363
|
+
kind, fields = _settled_fields(outcome)
|
|
364
|
+
txn.append(kind, {"cycleId": cycle["recordId"], **fields}, author=worker, source_run=run.source)
|
|
365
|
+
draft = txn.draft()
|
|
366
|
+
settled = next(c for c in draft["cycles"] if c["recordId"] == cycle["recordId"])
|
|
367
|
+
return {
|
|
368
|
+
"cycle": cycle["recordId"],
|
|
369
|
+
"results": [row["recordId"] for row in settled["results"]],
|
|
370
|
+
"failures": [
|
|
371
|
+
_failure_view(row) for row in settled["failures"] if row["recovery"] is None
|
|
372
|
+
],
|
|
373
|
+
"notDispatched": not_dispatched,
|
|
374
|
+
"phase": draft["phase"],
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _failure_view(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
379
|
+
return {
|
|
380
|
+
"recordId": row["recordId"],
|
|
381
|
+
"worker": row["author"],
|
|
382
|
+
"stage": row["stage"],
|
|
383
|
+
"attempts": row["attempts"],
|
|
384
|
+
"reason": row["reason"],
|
|
385
|
+
"unverifiedScope": (
|
|
386
|
+
f"question {row['questionId']}" if row.get("questionId")
|
|
387
|
+
else f"the research of {row['author']} in {row['cycleId']}"
|
|
388
|
+
),
|
|
389
|
+
"choices": list(_QUESTION_CHOICES if row.get("questionId") else CHOICES),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def choose_recovery(
|
|
394
|
+
store: DiscussionStore, run: DiscussionRun, failure_id: str, choice: str,
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
with transaction(store) as txn:
|
|
397
|
+
failure = next((row for row in txn.rows if row["recordId"] == failure_id), None)
|
|
398
|
+
if failure is None or failure["kind"] != "failure":
|
|
399
|
+
raise DiscussionWorkflowError(f"no failure {failure_id} in this discussion")
|
|
400
|
+
allowed = _QUESTION_CHOICES if failure.get("questionId") else CHOICES
|
|
401
|
+
if choice not in allowed:
|
|
402
|
+
raise DiscussionWorkflowError(
|
|
403
|
+
f"{failure_id} accepts {', '.join(allowed)}; a direct question goes to a "
|
|
404
|
+
"participant of a replacement run with `okstra discussion address`"
|
|
405
|
+
)
|
|
406
|
+
if choice == "retry" and failure["sourceRun"] != run.source.manifest:
|
|
407
|
+
raise DiscussionWorkflowError(
|
|
408
|
+
f"{failure_id} failed in {failure['sourceRun']}; a retry stays in that run's attempt record"
|
|
409
|
+
)
|
|
410
|
+
record, created = txn.append(
|
|
411
|
+
"recovery",
|
|
412
|
+
{"failureId": failure_id, "choice": choice, "worker": failure["author"],
|
|
413
|
+
"cycleId": failure["cycleId"], "questionId": failure.get("questionId", "")},
|
|
414
|
+
author=USER,
|
|
415
|
+
source_run=run.source,
|
|
416
|
+
)
|
|
417
|
+
draft = txn.draft()
|
|
418
|
+
return {"record": record, "created": created, "next": _recovery_next(store, draft, failure, choice),
|
|
419
|
+
"phase": draft["phase"]}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _recovery_next(
|
|
423
|
+
store: DiscussionStore, draft: Mapping[str, Any], failure: Mapping[str, Any], choice: str,
|
|
424
|
+
) -> str:
|
|
425
|
+
worker = failure["author"]
|
|
426
|
+
if failure.get("questionId"):
|
|
427
|
+
if choice == "retry":
|
|
428
|
+
return (
|
|
429
|
+
f"run `okstra discussion question-prompt --question {failure['questionId']}` again: it "
|
|
430
|
+
"names a new invocation for the retry; dispatch it and run `collect-answer`"
|
|
431
|
+
)
|
|
432
|
+
return f"{failure['questionId']} stays unanswered by {worker}; continue without it"
|
|
433
|
+
if choice == "retry":
|
|
434
|
+
cycle = next(c for c in draft["cycles"] if c["recordId"] == failure["cycleId"])
|
|
435
|
+
return (
|
|
436
|
+
f"materialize a new invocation id for {worker} with --assignment-ref initial/{worker}, "
|
|
437
|
+
f"--dispatch-kind {RESEARCH_DISPATCH_KIND} and --instruction {cycle['instructionPath']}, "
|
|
438
|
+
"dispatch it, then run `okstra discussion settle`"
|
|
439
|
+
)
|
|
440
|
+
if choice == "replace":
|
|
441
|
+
return (
|
|
442
|
+
"prepare a continuation run with `--discussion-start "
|
|
443
|
+
f"{store.task_key}` and the replacement roster; the completed results stay recorded "
|
|
444
|
+
"with their authors and reach the new run's research input"
|
|
445
|
+
)
|
|
446
|
+
return (
|
|
447
|
+
f"continue {failure['cycleId']} with the remaining participants; the research of {worker} "
|
|
448
|
+
"stays unverified and the reduced roster is recorded"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _final_state(store: DiscussionStore, run: DiscussionRun) -> tuple[Path, Mapping[str, Any]]:
|
|
453
|
+
state_path = run.project_path(str(run.manifest["convergenceStatePath"]))
|
|
454
|
+
try:
|
|
455
|
+
state = load_owned_object(state_path, artifact="convergence final state")
|
|
456
|
+
except JsonBoundaryError as exc:
|
|
457
|
+
raise DiscussionWorkflowError(
|
|
458
|
+
f"{exc}; finish the verification cycle with `okstra convergence finalize` first"
|
|
459
|
+
) from exc
|
|
460
|
+
errors = validate_final_state(state)
|
|
461
|
+
if errors:
|
|
462
|
+
raise DiscussionWorkflowError("the convergence final state is invalid: " + "; ".join(errors))
|
|
463
|
+
if not same_task(str(state.get("taskKey", "")), store.task_key):
|
|
464
|
+
raise DiscussionWorkflowError(
|
|
465
|
+
f"{run.relative(state_path)} belongs to {state.get('taskKey')!r}, not {store.task_key}"
|
|
466
|
+
)
|
|
467
|
+
return state_path, state
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def close_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
|
|
471
|
+
with transaction(store) as txn:
|
|
472
|
+
cycle = _cycle_of_run(txn.draft(), run)
|
|
473
|
+
if cycle is None or cycle["status"] == "superseded":
|
|
474
|
+
raise DiscussionWorkflowError(
|
|
475
|
+
"this run has no research cycle to close; a replaced cycle continues in a new run"
|
|
476
|
+
)
|
|
477
|
+
open_failures = [f["recordId"] for f in cycle["failures"] if f["recovery"] is None]
|
|
478
|
+
if open_failures:
|
|
479
|
+
raise DiscussionWorkflowError(
|
|
480
|
+
f"{', '.join(open_failures)} wait for the user's choice "
|
|
481
|
+
"(`okstra discussion recover`); the cycle closes after it"
|
|
482
|
+
)
|
|
483
|
+
state_path, state = _final_state(store, run)
|
|
484
|
+
claims = []
|
|
485
|
+
for finding in state["findings"]:
|
|
486
|
+
record, _ = txn.append("claim", _claim_fields(cycle["recordId"], finding),
|
|
487
|
+
author=str(finding["originWorker"]), source_run=run.source)
|
|
488
|
+
claims.append(record["recordId"])
|
|
489
|
+
close, created = txn.append(
|
|
490
|
+
"close",
|
|
491
|
+
{
|
|
492
|
+
"cycleId": cycle["recordId"],
|
|
493
|
+
"convergenceState": run.relative(state_path),
|
|
494
|
+
"stateDigest": _digest(state_path.read_bytes()),
|
|
495
|
+
"finalState": state["finalState"],
|
|
496
|
+
"stopReason": state["round2SkippedReason"],
|
|
497
|
+
},
|
|
498
|
+
author=LEADER,
|
|
499
|
+
source_run=run.source,
|
|
500
|
+
)
|
|
501
|
+
draft = txn.draft()
|
|
502
|
+
return {
|
|
503
|
+
"cycle": cycle["recordId"],
|
|
504
|
+
"close": close,
|
|
505
|
+
"created": created,
|
|
506
|
+
"claims": claims,
|
|
507
|
+
"remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
|
|
508
|
+
"phase": draft["phase"],
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _claim_fields(cycle_id: str, finding: Mapping[str, Any]) -> dict[str, Any]:
|
|
513
|
+
return {
|
|
514
|
+
"cycleId": cycle_id,
|
|
515
|
+
"findingId": finding["findingId"],
|
|
516
|
+
"summary": finding["summary"],
|
|
517
|
+
"category": finding["category"],
|
|
518
|
+
"discoveredBy": sorted(finding["discoveredBy"]),
|
|
519
|
+
"evidence": finding["originEvidence"],
|
|
520
|
+
"classification": finding["classification"],
|
|
521
|
+
"verdicts": [
|
|
522
|
+
{"round": row["round"], "worker": worker, "verdict": vote["verdict"],
|
|
523
|
+
"disagreeBasis": vote.get("disagreeBasis"), "explanation": vote["explanation"]}
|
|
524
|
+
for row in finding["rounds"]
|
|
525
|
+
for worker, vote in row["votes"].items()
|
|
526
|
+
],
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _question(draft: Mapping[str, Any], question_id: str) -> dict[str, Any]:
|
|
531
|
+
question = next((q for q in draft["questions"] if q["recordId"] == question_id), None)
|
|
532
|
+
if question is None:
|
|
533
|
+
raise DiscussionWorkflowError(f"no question {question_id} in this discussion")
|
|
534
|
+
if question["to"] == USER:
|
|
535
|
+
raise DiscussionWorkflowError(
|
|
536
|
+
f"{question_id} is for the user; put it to the user and record the answer with `answer`"
|
|
537
|
+
)
|
|
538
|
+
return question
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _question_names(run: DiscussionRun, draft: Mapping[str, Any], question: Mapping[str, Any]) -> dict[str, Any]:
|
|
542
|
+
retries = sum(1 for row in draft["failures"] if row.get("questionId") == question["recordId"])
|
|
543
|
+
slug = question["recordId"].lower() + (f"-r{retries}" if retries else "")
|
|
544
|
+
worker = question["to"]
|
|
545
|
+
prompts = run.run_dir / "prompts"
|
|
546
|
+
return {
|
|
547
|
+
"invocationId": f"discussion-question-{slug}-{worker}-{run.seq}",
|
|
548
|
+
"invocationPrefix": f"discussion-question-{question['recordId'].lower()}-",
|
|
549
|
+
"instruction": prompts / f"discussion-question-{slug}-discussion-{run.seq}.md",
|
|
550
|
+
"prompt": prompts / f"{worker}-worker-prompt-discussion-question-{slug}-discussion-{run.seq}.md",
|
|
551
|
+
"result": run.run_dir / "worker-results" / f"{worker}-worker-discussion-question-{slug}-discussion-{run.seq}.md",
|
|
552
|
+
"batch": run.run_dir / "state" / f"discussion-question-{slug}-batch-discussion-{run.seq}.json",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _question_instruction(question: Mapping[str, Any], draft: Mapping[str, Any]) -> str:
|
|
557
|
+
return (
|
|
558
|
+
f"# Discussion question {question['recordId']} — to {question['to']}\n\n"
|
|
559
|
+
f"{question['author']} asked you this question directly:\n\n{question['text']}\n\n"
|
|
560
|
+
"Answer it in your result file. Base the answer on the discussion record below and on "
|
|
561
|
+
"the evidence you inspect; give each factual claim its evidence location and say what you "
|
|
562
|
+
"could not establish. Your whole result file is recorded as your answer and every "
|
|
563
|
+
"participant reads it in their next input.\n\n"
|
|
564
|
+
+ render_draft_text(draft)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def question_dispatch(store: DiscussionStore, run: DiscussionRun, question_id: str) -> dict[str, Any]:
|
|
569
|
+
draft = read_draft(store)
|
|
570
|
+
question = _question(draft, question_id)
|
|
571
|
+
if question["answers"] or question["dropped"]:
|
|
572
|
+
raise DiscussionWorkflowError(f"{question_id} is already settled; ask a follow-up question instead")
|
|
573
|
+
waiting = [
|
|
574
|
+
row["recordId"] for row in draft["failures"]
|
|
575
|
+
if row.get("questionId") == question_id and row["recovery"] is None
|
|
576
|
+
]
|
|
577
|
+
if waiting:
|
|
578
|
+
raise DiscussionWorkflowError(
|
|
579
|
+
f"{', '.join(waiting)} waits for the user's choice (`okstra discussion recover`) "
|
|
580
|
+
"before the question is dispatched again"
|
|
581
|
+
)
|
|
582
|
+
worker = question["to"]
|
|
583
|
+
if worker not in {row["worker"] for row in run.analysers}:
|
|
584
|
+
raise DiscussionWorkflowError(
|
|
585
|
+
f"{worker} is not an analyser of {run.source.manifest}; address a participant of this run"
|
|
586
|
+
)
|
|
587
|
+
names = _question_names(run, draft, question)
|
|
588
|
+
if not names["instruction"].is_file():
|
|
589
|
+
names["instruction"].parent.mkdir(parents=True, exist_ok=True)
|
|
590
|
+
names["instruction"].write_text(_question_instruction(question, draft), encoding="utf-8")
|
|
591
|
+
entry = {
|
|
592
|
+
"invocation-id": names["invocationId"],
|
|
593
|
+
"worker-id": worker,
|
|
594
|
+
"audience": _DUTY,
|
|
595
|
+
"dispatch-kind": DISCUSSION_QUESTION_DISPATCH_KIND,
|
|
596
|
+
"assignment-ref": f"initial/{worker}",
|
|
597
|
+
"instruction": run.relative(names["instruction"]),
|
|
598
|
+
"prompt": run.relative(names["prompt"]),
|
|
599
|
+
"result": run.relative(names["result"]),
|
|
600
|
+
}
|
|
601
|
+
write_owned_object_atomic(names["batch"], {"invocations": [entry]}, artifact="agent prompt batch")
|
|
602
|
+
return {
|
|
603
|
+
"question": question_id,
|
|
604
|
+
"worker": worker,
|
|
605
|
+
"invocationId": names["invocationId"],
|
|
606
|
+
"dispatchKind": DISCUSSION_QUESTION_DISPATCH_KIND,
|
|
607
|
+
"instructionPath": entry["instruction"],
|
|
608
|
+
"resultPath": entry["result"],
|
|
609
|
+
"batchPath": run.relative(names["batch"]),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def collect_answer(store: DiscussionStore, run: DiscussionRun, question_id: str) -> dict[str, Any]:
|
|
614
|
+
draft = read_draft(store)
|
|
615
|
+
question = _question(draft, question_id)
|
|
616
|
+
names = _question_names(run, draft, question)
|
|
617
|
+
outcomes = [
|
|
618
|
+
outcome for outcome in _outcomes(run, _team_state(run), question["to"], DISCUSSION_QUESTION_DISPATCH_KIND)
|
|
619
|
+
if outcome["invocationId"].startswith(names["invocationPrefix"])
|
|
620
|
+
]
|
|
621
|
+
if not outcomes:
|
|
622
|
+
raise DiscussionWorkflowError(
|
|
623
|
+
f"no dispatch of {question_id} in {run.source.manifest}; dispatch the batch "
|
|
624
|
+
"`okstra discussion question-prompt` wrote first"
|
|
625
|
+
)
|
|
626
|
+
kind, fields = _settled_fields(outcomes[-1])
|
|
627
|
+
if kind == "result":
|
|
628
|
+
result_path = run.project_path(fields["resultPath"])
|
|
629
|
+
fields = {"questionId": question_id, "text": result_path.read_text(encoding="utf-8"),
|
|
630
|
+
"resultPath": fields["resultPath"]}
|
|
631
|
+
kind = "answer"
|
|
632
|
+
else:
|
|
633
|
+
fields = {"cycleId": "", "questionId": question_id, **fields}
|
|
634
|
+
with transaction(store) as txn:
|
|
635
|
+
record, created = txn.append(kind, fields, author=question["to"], source_run=run.source)
|
|
636
|
+
after = txn.draft()
|
|
637
|
+
return {"record": record, "created": created, "phase": after["phase"],
|
|
638
|
+
"pendingForParticipants": after["pendingForParticipants"]}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def status(draft: Mapping[str, Any]) -> dict[str, Any]:
|
|
642
|
+
by_id = {q["recordId"]: q for q in draft["questions"]}
|
|
643
|
+
next_question = by_id.get(draft["nextForUser"]) if draft["nextForUser"] else None
|
|
644
|
+
return {
|
|
645
|
+
"phase": draft["phase"],
|
|
646
|
+
"draftVersion": draft["recordsDigest"],
|
|
647
|
+
"completedRevision": draft["completedRevision"],
|
|
648
|
+
"revisions": [{key: row[key] for key in ("revisionId", "contentDigest")} for row in draft["revisions"]],
|
|
649
|
+
"basedOn": (
|
|
650
|
+
{"revisionId": draft["basedOn"]["revisionId"], "reason": draft["basedOn"]["reason"]}
|
|
651
|
+
if draft["basedOn"] else None
|
|
652
|
+
),
|
|
653
|
+
"nextForUser": (
|
|
654
|
+
{key: next_question[key] for key in ("recordId", "author", "text")} if next_question else None
|
|
655
|
+
),
|
|
656
|
+
"pendingForUser": draft["pendingForUser"],
|
|
657
|
+
"pendingForParticipants": draft["pendingForParticipants"],
|
|
658
|
+
"unrecoveredFailures": [
|
|
659
|
+
_failure_view(row) for row in draft["failures"] if row["recordId"] in draft["unrecoveredFailures"]
|
|
660
|
+
],
|
|
661
|
+
"remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
|
|
662
|
+
"cycles": [
|
|
663
|
+
{"recordId": c["recordId"], "status": c["status"], "run": c["sourceRun"],
|
|
664
|
+
"participants": c["roster"]}
|
|
665
|
+
for c in draft["cycles"]
|
|
666
|
+
],
|
|
667
|
+
}
|