diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 59f6246..687dd90 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1556,7 +1556,7 @@ Known domain failures: `400` Scheduling/input errors; `404` NOT_FOUND. #### `POST /api/ideas/{ident}/adopt` -Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo. +Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo. | Parameter | Location | Type | Required | Default / description | | --- | --- | --- | --- | --- | @@ -1566,7 +1566,7 @@ Body: `application/json`: `IdeaAdoptRequest`; optional. Success `200`: `application/json`: `GraphState`. -Known domain failures: `404` NOT_FOUND; `409` REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE; `422` INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS. +Known domain failures: `404` NOT_FOUND; `409` REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED; `422` INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS. #### `PATCH /api/projects/{ident}/objective` diff --git a/packages/contracts/openapi.json b/packages/contracts/openapi.json index f8c1fd9..f2795bf 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -13274,7 +13274,7 @@ }, "/api/ideas/{ident}/adopt": { "post": { - "description": "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", + "description": "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", "operationId": "adopt_api_ideas__ident__adopt_post", "parameters": [ { @@ -13345,7 +13345,7 @@ } } }, - "description": "REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE" + "description": "REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED" }, "422": { "content": { diff --git a/services/api/contracts.py b/services/api/contracts.py index 20a6237..21a0448 100644 --- a/services/api/contracts.py +++ b/services/api/contracts.py @@ -659,7 +659,7 @@ def operation(method: str, path: str, tag: str, summary: str, description: str, ("/api/analysis/run", "analysis", "Enqueue an analysis task over declared real inputs; output fields depend on the selected analysis type.")): operation("post", path, "Research", f"Enqueue {kind} work", description + " Return Run, not the final scientific output.", ref("Run"), body="QueuedTaskRequest", errors={400: "Scheduling/input errors", 404: "NOT_FOUND"}) operation("post", "/api/ideas/{ident}/adopt", "Research", "Adopt an idea into an editable execution path", - "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", ref("GraphState"), body="IdeaAdoptRequest", errors={404: "NOT_FOUND", 409: "REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE", 422: "INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS"}) + "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", ref("GraphState"), body="IdeaAdoptRequest", errors={404: "NOT_FOUND", 409: "REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED", 422: "INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS"}) operation("post", "/api/theory/check", "Research", "Evaluate a restricted symbolic expression", "Require project_id/expression; variable defaults x. kind differentiate/solve/numeric selects an operation; any other/omitted kind simplifies. values supplies numeric substitutions. Restricted arithmetic grammar permits sin/cos/exp/log/sqrt/abs, bounded constants/powers and no arbitrary eval. Save a checked Derivation RecordItem, with result/latex and an explicit supplied-expression scope.", ref("RecordItem"), body="SymbolicCheckRequest", errors={400: "INVALID_EXPRESSION", 404: "NOT_FOUND", 422: "EXPRESSION_FAILED"}) operation("post", "/api/experiments/{ident}/launch", "Experiments", "Enqueue a saved experiment specification", diff --git a/services/api/resources.py b/services/api/resources.py index 59b66b7..92eb9b7 100644 --- a/services/api/resources.py +++ b/services/api/resources.py @@ -125,6 +125,8 @@ def adopt(ident:str,body:dict=Body(default={})): return g if body.get('expected_revision',p.revision)!=p.revision: error('REVISION_CONFLICT','The project changed; refresh before adopting the idea',409) + if any(node.get('adopted_idea_id')==idea.id for node in g['nodes']): + error('IDEA_ALREADY_ADOPTED','This idea has already been adopted; use its existing execution path',409) original=deepcopy(g); commands=[] config=deepcopy(body.get('config',idea.data.get('experiment_config',{}))) if not isinstance(config,dict): error('INVALID_EXPERIMENT_CONFIG','Experiment config must be an object',422) diff --git a/tests/test_idea_adoption.py b/tests/test_idea_adoption.py index aa95104..38aed42 100644 --- a/tests/test_idea_adoption.py +++ b/tests/test_idea_adoption.py @@ -93,6 +93,44 @@ def concurrent(_): return adopted(client,i,request_id='one-adoption',expected_re assert s.scalar(select(func.count()).select_from(CommandReceipt).where(CommandReceipt.project_id==p['id']))==1 +def case_repeat_adoption_rejected_without_shared_receipt(client): + p=create(client); i=idea(client,p) + g=ok(adopted(client,i)) + for body in ({},{'request_id':'different-adoption','title':'Another title'}): + rejected=adopted(client,i,**body) + assert rejected.status_code==409,rejected.text + assert rejected.json()['detail']['code']=='IDEA_ALREADY_ADOPTED' + assert graph(client,p)==g + assert ok(client.get(f"/api/ideas/{i['id']}"))['status']=='adopted' + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + from sqlalchemy import select,func + from services.api.db import Session,CommandReceipt + with Session() as s: + assert s.scalar(select(func.count()).select_from(CommandReceipt).where(CommandReceipt.project_id==p['id']))==1 + + +def case_adoption_after_undo_can_create_a_new_path(client): + p=create(client); i=idea(client,p) + first=ok(adopted(client,i)) + undone=ok(command(client,p,'undo'))['graph'] + assert undone['nodes']==[] + second=ok(adopted(client,i,request_id='after-undo',expected_revision=undone['revision'])) + assert second['revision']==undone['revision']+1 and len(second['nodes'])==3 + assert {n['id'] for n in first['nodes']}.isdisjoint(n['id'] for n in second['nodes']) + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + + +def case_concurrent_adoptions_without_shared_receipt(client): + p=create(client); i=idea(client,p) + with ThreadPoolExecutor(max_workers=5) as pool: + results=list(pool.map(lambda _:adopted(client,i),range(5))) + assert sorted(r.status_code for r in results)==[200,409,409,409,409],[(r.status_code,r.text) for r in results] + assert all(r.json()['detail']['code']=='IDEA_ALREADY_ADOPTED' for r in results if r.status_code==409) + g=graph(client,p) + assert g['revision']==1 and len(g['nodes'])==3 and len(g['edges'])==2 + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + + def case_completed_producer_file_reused_but_stale_rejected(client): p=create(client); i=idea(client,p) g=ok(adopted(client,i,config={'kind':'command','command':['python','pilot.py']}))