From 5c9420ed9c611d503eed34972b8b42170f7b4cfd Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 20:41:57 +0000 Subject: [PATCH] feat: require estimate before create and start (Closes #849) --- frontend/create-and-start.js | 52 ++++++++++++++++++- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 30 ++++++++++- frontend/index.html | 4 ++ frontend/issue-filing-review.js | 9 ++++ frontend/issue-outbox.js | 27 ++++++++-- src/frontend_bundle.py | 2 +- tests/test_create_and_start.py | 86 ++++++++++++++++++++++++++++--- tests/test_issue_filing_review.py | 17 ++++++ tests/test_issue_outbox.py | 37 +++++++++++++ 10 files changed, 253 insertions(+), 14 deletions(-) diff --git a/frontend/create-and-start.js b/frontend/create-and-start.js index 4b5b9ea..d19c5d9 100644 --- a/frontend/create-and-start.js +++ b/frontend/create-and-start.js @@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno return todayWork.read().length < todayWork.limit; } - function complete(issue) { + function capacity(value) { + if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' }; + const minutes = Number(value); + if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) { + return { valid: false, reason: 'range' }; + } + const plan = todayWork.planning(); + const capacityMinutes = plan.capacity_minutes; + if (!Number.isInteger(capacityMinutes)) { + return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null, + remainingMinutes: null, projectedMinutes: null, fits: true }; + } + const plannedMinutes = todayWork.read().reduce( + (total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0 + ); + const remainingMinutes = capacityMinutes - plannedMinutes; + const projectedMinutes = remainingMinutes - minutes; + return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes, + projectedMinutes, fits: projectedMinutes >= 0 }; + } + + function capacityMessage(result) { + if (!result.valid) return result.reason === 'required' + ? 'Required only for Create & start.' + : 'Use a whole number from 5 to 1440 minutes.'; + if (result.capacityMinutes === null) { + return result.minutes + ' min · Set a Today budget to see remaining time.'; + } + return result.fits + ? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.' + : result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.'; + } + + function complete(issue, estimateMinutes) { const identity = todayWork.identity(issue); if (!identity || completed.has(identity)) return 'exists'; const added = todayWork.add(issue); @@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno announce('Created, but Today could not be saved on this device.'); return 'unavailable'; } + let estimatedPlan = null; + if (Number.isInteger(estimateMinutes)) { + estimatedPlan = todayWork.planning(); + estimatedPlan.estimates[identity] = estimateMinutes; + if (!todayWork.replacePlanning(estimatedPlan)) { + announce('Created and added to Today, but its estimate could not be saved on this device.'); + return 'estimate-unavailable'; + } + } if (!todaySync.enqueue('add', identity)) { announce('Created and saved to Today on this device, but account sync is unavailable.'); return 'sync-unavailable'; } + if (estimatedPlan) { + if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) { + announce('Created and estimated on this device, but Today plan sync is unavailable.'); + return 'sync-unavailable'; + } + } completed.add(identity); refresh(); todaySync.flush(); @@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno return 'started'; } - return { available, complete }; + return { available, capacity, capacityMessage, complete }; } if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index c68c382..57bccf7 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -292,6 +292,9 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); } .create-issue-actions button { min-height:44px; max-width:100%; width:100%; } .create-issue-actions #create-issue-status { grid-column:1/-1; } +.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; } +.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; } +.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); } .draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); } .draft-filing-session[hidden] { display:none; } .draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 0548e90..204c7be 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1594,6 +1594,12 @@ }, announce: message => { qs('#my-work-action-status').textContent = message; }, }); + function renderCreateStartCapacity() { + const result = createAndStart.capacity(qs('#create-issue-estimate').value); + qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result); + return result; + } + qs('#create-issue-estimate').addEventListener('input', renderCreateStartCapacity); const queueToday = createQueueToday({ todayWork, @@ -3988,7 +3994,7 @@ const created = lastMyWork.find(item => item.kind === 'issue' && item.repository === completion.issue?.repository && item.number === completion.issue?.number); if (!created) return; - const outcome = createAndStart.complete(created); + const outcome = createAndStart.complete(created, completion.estimateMinutes); if (outcome === 'started' || outcome === 'exists') { issueOutbox.completeIntent(completion.id, activeFlushLogin); startedCompletions.add(created.repository + '#' + created.number); @@ -5271,6 +5277,8 @@ (event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign'); const intent = pendingIssueFilingIntent; createAndStartRequested = intent === 'create-and-start'; + const startCapacity = createAndStartRequested + ? createAndStart.capacity(qs('#create-issue-estimate').value) : null; const captureDraft = currentIssueCaptureDraft(); if (!captureDraft.repository || !captureDraft.title) { qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; @@ -5288,6 +5296,21 @@ qs('#create-and-start-issue').focus(); return; } + if (createAndStartRequested && !startCapacity.valid) { + qs('#create-issue-status').textContent = startCapacity.reason === 'required' + ? 'Add a work estimate before creating and starting.' + : 'Use a whole-number estimate from 5 to 1440 minutes.'; + qs('#create-issue-estimate').focus(); + renderCreateStartCapacity(); + return; + } + if (createAndStartRequested && !startCapacity.fits) { + qs('#create-issue-status').textContent = 'This would put Today ' + + Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.'; + qs('#create-issue-estimate').focus(); + renderCreateStartCapacity(); + return; + } qs('#create-issue-status').textContent = 'Preparing complete filing review…'; try { const evidence = await createIssueAttachmentController.serialize(); @@ -5298,7 +5321,10 @@ milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '', ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}), ...(rUC ? { sourceCaptureId: rUC } : {}), - ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), + ...(createAndStartRequested ? { + completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes, + todayCapacity: startCapacity, + } : {}), }; filingReview.open({draft: durableDraft, intent}, event.submitter || qs('#submit-new-issue')); qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.'; diff --git a/frontend/index.html b/frontend/index.html index 0f62775..473fdcd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -838,6 +838,10 @@
+
The issue will be assigned to you.
diff --git a/frontend/issue-filing-review.js b/frontend/issue-filing-review.js index 848c841..4d4f897 100644 --- a/frontend/issue-filing-review.js +++ b/frontend/issue-filing-review.js @@ -107,11 +107,20 @@ const owner = draft.assignee ? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')' : 'Assigned to you'; + const planning = []; + if (Number.isInteger(draft.estimateMinutes)) { + planning.push('Estimate: ' + draft.estimateMinutes + ' min'); + const projected = draft.todayCapacity?.projectedMinutes; + if (Number.isInteger(projected)) planning.push(projected >= 0 + ? 'Today after start: ' + projected + ' min free' + : 'Today after start: ' + Math.abs(projected) + ' min over capacity'); + } options.metadata.textContent = [ labels.length ? 'Labels: ' + labels.join(', ') : 'No labels', 'Milestone: ' + milestone, 'Due: ' + dueDate, owner, + ...planning, ].join(' · '); if (options.blockerList) { options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => { diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index f10bef3..bcab870 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -91,7 +91,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId; - if (draft?.completionIntent === 'create-and-start' && !assignee) item.completionIntent = 'create-and-start'; + if (draft?.completionIntent === 'create-and-start' && !assignee) { + item.completionIntent = 'create-and-start'; + const estimateMinutes = Number(draft?.estimateMinutes); + if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { + item.estimateMinutes = estimateMinutes; + } + } const attachment = captureAttachment(draft?.attachment); if (attachment) item.attachment = attachment; const attachments = captureAttachments(draft?.attachments); @@ -216,6 +222,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge || JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) || item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName || item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate + || item.estimateMinutes !== (() => { + const value = Number(draft?.estimateMinutes); + return Number.isInteger(value) && value >= 5 && value <= 1440 ? value : undefined; + })() || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null); updated = { ...item, @@ -227,8 +237,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; - if (draft?.completionIntent === 'create-and-start' && !nextAssignee) updated.completionIntent = 'create-and-start'; - else delete updated.completionIntent; + if (draft?.completionIntent === 'create-and-start' && !nextAssignee) { + updated.completionIntent = 'create-and-start'; + const estimateMinutes = Number(draft?.estimateMinutes); + if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { + updated.estimateMinutes = estimateMinutes; + } else delete updated.estimateMinutes; + } else { + delete updated.completionIntent; + delete updated.estimateMinutes; + } if (nextAssignee === undefined) { delete updated.assignee; delete updated.assigneeName; @@ -460,6 +478,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge intent: result.item.completionIntent, ownerLogin: result.item.ownerLogin, operationId: result.item.operationId, + estimateMinutes: result.item.estimateMinutes, issue: result.issue, }); } @@ -499,6 +518,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge intent: result.item.completionIntent, ownerLogin: result.item.ownerLogin, operationId: result.item.operationId, + estimateMinutes: result.item.estimateMinutes, issue: result.issue, }] : [], remaining: read(), blocked: 0, @@ -543,6 +563,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge intent: item.completionIntent, ownerLogin: item.ownerLogin, operationId: item.operationId, + estimateMinutes: item.estimateMinutes, issue: item.deliveredIssue, })); } diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index b303515..b1f0fb0 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -32,7 +32,7 @@ FEATURE_SOURCES = { "today-timer": ( "static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", - "static/assign-and-start.js", "static/queue-today.js", + "static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", diff --git a/tests/test_create_and_start.py b/tests/test_create_and_start.py index 2ca7d53..de4ad09 100644 --- a/tests/test_create_and_start.py +++ b/tests/test_create_and_start.py @@ -16,7 +16,7 @@ def run_node(script): ).stdout) -def test_confirmed_issue_is_planned_synced_warmed_and_started_once(): +def test_confirmed_issue_persists_and_syncs_estimate_before_starting_once(): script = f""" const fs = require('fs'); if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{ @@ -26,13 +26,22 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{ const calls = []; const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}}; const flow = createCreateAndStart({{ - todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}}, - todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}}, + todayWork: {{ + limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', + add:()=>{{calls.push('add');return 'added';}}, + planning:()=>({{capacity_minutes:120,estimates:{{}}}}), + replacePlanning:plan=>{{calls.push('plan:'+JSON.stringify(plan));return true;}}, + }}, + todaySync: {{ + enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, + enqueueConfiguration:(capacity,estimates)=>{{calls.push('configure:'+capacity+':'+JSON.stringify(estimates));return true;}}, + flush:()=>calls.push('flush') + }}, refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number), announce:message=>calls.push('announce:'+message), }}); - const result = flow.complete(issue); - flow.complete(issue); + const result = flow.complete(issue, 45); + flow.complete(issue, 45); process.stdout.write(JSON.stringify({{available:true,result,calls}})); }} """ @@ -42,7 +51,9 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{ "result": "started", "calls": [ "add", + 'plan:{"capacity_minutes":120,"estimates":{"issue:stackchain/dashboard:387:":45}}', "add:issue:stackchain/dashboard:387:", + 'configure:120:{"issue:stackchain/dashboard:387:":45}', "refresh", "flush", "warm", @@ -63,7 +74,12 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_ assert "const createAndStart = createCreateAndStart({" in dashboard assert "event.submitter.id === 'create-and-start-issue'" in dashboard assert "review.intent === 'create-and-start'" in dashboard - assert "createAndStart.complete(created)" in dashboard + assert 'id="create-issue-estimate"' in html + assert 'min="5" max="1440" step="5"' in html + assert "createAndStart.capacity(qs('#create-issue-estimate').value)" in dashboard + assert "estimateMinutes: startCapacity.minutes" in dashboard + assert "createAndStart.complete(created, completion.estimateMinutes)" in dashboard + assert ".create-issue-estimate" in css assert ".create-issue-actions" in css and "grid-template-columns" in css assert "BASE + 'static/create-and-start.js'" in worker @@ -126,3 +142,61 @@ process.stdout.write(JSON.stringify({{first,second,calls}})); "refresh", "flush", "warm", "start", "Created, added to Today, and ready to work.", ] + + +def test_create_and_start_reports_validation_and_remaining_today_capacity(): + script = f""" +const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))}); +const flow=createCreateAndStart({{ + todayWork:{{limit:5,read:()=>['first','second'],planning:()=>({{ + capacity_minutes:120,estimates:{{first:30,second:40}} + }})}}, +}}); +process.stdout.write(JSON.stringify({{ + empty:flow.capacity(''),tooSmall:flow.capacity('4'),fits:flow.capacity('45'),over:flow.capacity('55') +}})); +""" + assert run_node(script) == { + "empty": {"valid": False, "reason": "required"}, + "tooSmall": {"valid": False, "reason": "range"}, + "fits": {"valid": True, "minutes": 45, "capacityMinutes": 120, "plannedMinutes": 70, + "remainingMinutes": 50, "projectedMinutes": 5, "fits": True}, + "over": {"valid": True, "minutes": 55, "capacityMinutes": 120, "plannedMinutes": 70, + "remainingMinutes": 50, "projectedMinutes": -5, "fits": False}, + } + + +def test_create_and_start_does_not_start_when_estimate_cannot_be_saved(): + script = f""" +const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[]; +const flow=createCreateAndStart({{ + todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added', + planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>false}}, + todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>true,flush:()=>calls.push('flush')}}, + refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'), + announce:message=>calls.push(message), +}}); +process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}})); +""" + assert run_node(script) == { + "result": "estimate-unavailable", + "calls": ["Created and added to Today, but its estimate could not be saved on this device."], + } + + +def test_create_and_start_does_not_start_when_estimate_sync_cannot_be_queued(): + script = f""" +const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[]; +const flow=createCreateAndStart({{ + todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added', + planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>true}}, + todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>false,flush:()=>calls.push('flush')}}, + refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'), + announce:message=>calls.push(message), +}}); +process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}})); +""" + assert run_node(script) == { + "result": "sync-unavailable", + "calls": ["Created and estimated on this device, but Today plan sync is unavailable."], + } diff --git a/tests/test_issue_filing_review.py b/tests/test_issue_filing_review.py index 07f1f3b..778fab6 100644 --- a/tests/test_issue_filing_review.py +++ b/tests/test_issue_filing_review.py @@ -113,6 +113,23 @@ process.stdout.write(JSON.stringify({{issueType:issueType.textContent}})); assert run_node(script)["issueType"] == "Bug report" +def test_create_and_start_review_shows_estimate_and_projected_capacity(): + script = f""" +const createReview=require({json.dumps(str(MODULE))}); +function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}} +const metadata=target(); +const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(), + repository:target(),intent:target(),title:target(),body:target(),metadata, + document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}}); +review.open({{draft:{{repository:'o/r',title:'Sized work',estimateMinutes:45, + todayCapacity:{{remainingMinutes:60,projectedMinutes:15}}}},intent:'create-and-start'}},target()); +process.stdout.write(JSON.stringify({{metadata:metadata.textContent}})); +""" + metadata = run_node(script)["metadata"] + assert "Estimate: 45 min" in metadata + assert "Today after start: 15 min free" in metadata + + def test_escape_returns_to_the_unchanged_issue_form(): script = f""" const createReview = require({json.dumps(str(MODULE))}); diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 52f603f..d660644 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -105,6 +105,43 @@ process.stdout.write(JSON.stringify(updated)); assert output["operationId"] != "1" +def test_create_and_start_estimate_survives_outbox_delivery_and_completion(): + script = f""" +const createIssueOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map(); +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'estimated-start', + fetchJson:async()=>({{number:849,title:'Estimated work'}}), +}}); +const queued=outbox.enqueue({{repository:'stackchain/dashboard',title:'Estimated work', + completionIntent:'create-and-start',estimateMinutes:45}}); +(async()=>{{ + const delivered=await outbox.flush('timmy'); + process.stdout.write(JSON.stringify({{queued,completion:delivered.completions[0]}})); +}})(); +""" + output = run_node(script) + + assert output["queued"]["estimateMinutes"] == 45 + assert output["completion"]["estimateMinutes"] == 45 + + +def test_editing_queued_start_estimate_rotates_the_delivery_operation(): + script = f""" +const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();let sequence=0; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}}); +const queued=outbox.enqueue({{repository:'o/r',title:'Sized',completionIntent:'create-and-start',estimateMinutes:30}}); +const updated=outbox.update(queued.id,{{...queued,estimateMinutes:60}}); +process.stdout.write(JSON.stringify({{queued,updated}})); +""" + output = run_node(script) + + assert output["updated"]["estimateMinutes"] == 60 + assert output["updated"]["operationId"] != output["queued"]["operationId"] + + def test_issue_outbox_preserves_bounded_ordered_evidence_bundle(): script = f""" const createIssueOutbox=require({json.dumps(str(OUTBOX))});