wicked-crew 0.13.2 → 0.14.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/dist/api/capture.d.ts +3 -3
- package/dist/api/capture.d.ts.map +1 -1
- package/dist/api/capture.js +12 -5
- package/dist/api/capture.js.map +1 -1
- package/dist/api/diagnostics.d.ts +18 -2
- package/dist/api/diagnostics.d.ts.map +1 -1
- package/dist/api/diagnostics.js +54 -9
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/gate-cache.d.ts +7 -0
- package/dist/api/gate-cache.d.ts.map +1 -1
- package/dist/api/gate-cache.js +2 -0
- package/dist/api/gate-cache.js.map +1 -1
- package/dist/api/policy-landing.d.ts +15 -2
- package/dist/api/policy-landing.d.ts.map +1 -1
- package/dist/api/policy-landing.js +55 -7
- package/dist/api/policy-landing.js.map +1 -1
- package/dist/api/routes.d.ts +12 -0
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +421 -82
- package/dist/api/routes.js.map +1 -1
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +52 -1
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/reassign-refusal.d.ts +7 -0
- package/dist/core/reassign-refusal.d.ts.map +1 -0
- package/dist/core/reassign-refusal.js +42 -0
- package/dist/core/reassign-refusal.js.map +1 -0
- package/dist/decisions/derive.d.ts +8 -0
- package/dist/decisions/derive.d.ts.map +1 -1
- package/dist/decisions/derive.js +27 -1
- package/dist/decisions/derive.js.map +1 -1
- package/dist/decisions/ingest.d.ts.map +1 -1
- package/dist/decisions/ingest.js +5 -1
- package/dist/decisions/ingest.js.map +1 -1
- package/dist/decisions/land.d.ts +5 -1
- package/dist/decisions/land.d.ts.map +1 -1
- package/dist/decisions/land.js +87 -17
- package/dist/decisions/land.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +8 -2
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +7 -1
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/studio/assets/index-BA69zWzx.js +791 -0
- package/dist/studio/assets/index-GSpTTvH0.css +32 -0
- package/dist/studio/editors/wicked-deck/editor.json +1 -1
- package/dist/studio/editors/wicked-deck/index.html +1 -1
- package/dist/studio/editors/wicked-doc/editor.json +1 -1
- package/dist/studio/editors/wicked-doc/index.html +1 -1
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/editors/wicked-video/editor.json +1 -1
- package/dist/studio/editors/wicked-video/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +636 -95
- package/dist/watch/checks/discovery.d.ts.map +1 -1
- package/dist/watch/checks/discovery.js +2 -1
- package/dist/watch/checks/discovery.js.map +1 -1
- package/endpoint-manifest.json +15 -1
- package/package.json +4 -4
- package/dist/studio/assets/index-4EBan87x.css +0 -32
- package/dist/studio/assets/index-CjssTXGs.js +0 -771
package/dist/api/routes.js
CHANGED
|
@@ -30,6 +30,7 @@ import { SeatContainerStatus, isContainerLoginCli, isContainerSeatFloor, loginDe
|
|
|
30
30
|
import { ChatTurnIndex } from './chat-turns.js';
|
|
31
31
|
import { ChatScopeIndex, chatScopeDeps, chatScopeStatement, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
|
|
32
32
|
import { ASK_TURN_BUDGET_SECS, AskPathIndex, answerStep, isAskTurn } from './ask-paths.js';
|
|
33
|
+
import { reassignRefusedSeats } from '../core/reassign-refusal.js';
|
|
33
34
|
import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
34
35
|
import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isGitStopped, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
|
|
35
36
|
import { DocRunIndex } from '../interactive/doc-run-index.js';
|
|
@@ -99,7 +100,7 @@ import { LOCAL_ACTOR, trustAtLeast } from './auth.js';
|
|
|
99
100
|
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
100
101
|
export { API_PREFIX } from './api-prefix.js';
|
|
101
102
|
import { API_PREFIX } from './api-prefix.js';
|
|
102
|
-
import { landPolicyRule, policyProposalToRule } from './policy-landing.js';
|
|
103
|
+
import { landPolicyRule, policyProposalToRule, withdrawLandedRule } from './policy-landing.js';
|
|
103
104
|
import { DecisionError, DecisionService } from '../decisions/land.js';
|
|
104
105
|
import { ingestDecision } from '../decisions/ingest.js';
|
|
105
106
|
import { registerDecisionRoutes } from '../decisions/routes.js';
|
|
@@ -548,6 +549,22 @@ export const RetireMemorySchema = z.object({
|
|
|
548
549
|
export const RetireMemoryItemSchema = z.object({
|
|
549
550
|
memory_id: z.string().min(1).max(512),
|
|
550
551
|
}).strict();
|
|
552
|
+
/** `POST /memory/edit` body (wicked-studio's Memory page): ONE memory's new words. `.strict()`: an
|
|
553
|
+
* unknown field is a 400, never read as something wider than one memory. */
|
|
554
|
+
export const EditMemorySchema = z.object({
|
|
555
|
+
memory_id: z.string().min(1).max(512),
|
|
556
|
+
content: z.string().min(1).max(4000),
|
|
557
|
+
}).strict();
|
|
558
|
+
/** estate `memory.capture`'s `kind` for a tier — the kind natural to it, as estate's own proposal
|
|
559
|
+
* approve picks (`kind_for_tier`), so an edited memory keeps the kind its tier implies. */
|
|
560
|
+
const KIND_FOR_TIER = {
|
|
561
|
+
working: 'working', episodic: 'episode', semantic: 'fact', procedural: 'skill', archival: 'archive',
|
|
562
|
+
};
|
|
563
|
+
/** The id `POST /memory/edit` probes erase-by-id with: no memory carries it, so the erase deletes nothing. */
|
|
564
|
+
const MEMORY_EDIT_PROBE_ID = 'crew:erase-by-id-probe';
|
|
565
|
+
/** The memory ids with an edit in flight: estate has no compare-and-swap, and two edits of one memory
|
|
566
|
+
* racing would each write a copy. One daemon, so one edit per memory at a time is enough. */
|
|
567
|
+
const memoryEditsInFlight = new Set();
|
|
551
568
|
/** The error an estate from before wicked-estate#188 answers to `memory.erase {id}`: it knows only the
|
|
552
569
|
* subtree erase, so it refuses the call (deleting nothing) for want of a `scope_prefix`. */
|
|
553
570
|
const ESTATE_NO_ERASE_BY_ID = /scope_prefix \(non-empty\) required/i;
|
|
@@ -1036,6 +1053,13 @@ runtime = {}) {
|
|
|
1036
1053
|
if (isAskTurn(view))
|
|
1037
1054
|
view.session.ask_turn = true;
|
|
1038
1055
|
}
|
|
1056
|
+
// crew#1058: the seats a reassign of each unit is refused for (the work's authors under a
|
|
1057
|
+
// distinct-evaluator contract) — the same rule the reassign route applies. ABSENT when none.
|
|
1058
|
+
for (const unit of view.units) {
|
|
1059
|
+
const refused = reassignRefusedSeats(view, unit.ord);
|
|
1060
|
+
if (refused.length > 0)
|
|
1061
|
+
unit.reassign_refused = refused;
|
|
1062
|
+
}
|
|
1039
1063
|
return view;
|
|
1040
1064
|
};
|
|
1041
1065
|
// Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
|
|
@@ -1572,6 +1596,15 @@ runtime = {}) {
|
|
|
1572
1596
|
};
|
|
1573
1597
|
if (!worktreeLive)
|
|
1574
1598
|
return fromBranchOr409();
|
|
1599
|
+
// crew#1057: the delivered-worktree sweep (`git worktree remove`) deletes the worktree's files,
|
|
1600
|
+
// its `.git` link among them, BEFORE the directory itself. A bound run's worktree with no `.git`
|
|
1601
|
+
// link is being swept: git run in it finds the registered repo above it instead, and answers
|
|
1602
|
+
// about that checkout (an empty diff) or fails with no named error ("no merge base between
|
|
1603
|
+
// origin/main and HEAD"). Such a worktree is gone, checked before the read and again after it.
|
|
1604
|
+
const bound = resolved.session.repo_ref !== null;
|
|
1605
|
+
const worktreeIntact = () => existsSync(workdir) && (!bound || existsSync(join(workdir, '.git')));
|
|
1606
|
+
if (!worktreeIntact())
|
|
1607
|
+
return fromBranchOr409();
|
|
1575
1608
|
// Narrowing is WORKTREE-scoped: a contained-but-outside-the-worktree path (extra write
|
|
1576
1609
|
// root / repo root) is a valid FILE read but has no meaning as a diff pathspec — rejected
|
|
1577
1610
|
// explicitly here rather than handing git a `../`-prefixed pathspec and surfacing its
|
|
@@ -1592,22 +1625,31 @@ runtime = {}) {
|
|
|
1592
1625
|
try {
|
|
1593
1626
|
if (defaultBase !== undefined) {
|
|
1594
1627
|
try {
|
|
1595
|
-
|
|
1628
|
+
const read = await worktreeDiff(workdir, rel, defaultBase);
|
|
1629
|
+
if (!worktreeIntact())
|
|
1630
|
+
return fromBranchOr409();
|
|
1631
|
+
return { ...read, source: 'worktree', base: defaultBase };
|
|
1596
1632
|
}
|
|
1597
1633
|
catch (err) {
|
|
1598
1634
|
if (!(err instanceof UnresolvableDiffBaseError))
|
|
1599
1635
|
throw err;
|
|
1600
1636
|
}
|
|
1601
1637
|
}
|
|
1602
|
-
|
|
1638
|
+
const read = await worktreeDiff(workdir, rel, rawBase);
|
|
1639
|
+
if (!worktreeIntact())
|
|
1640
|
+
return fromBranchOr409();
|
|
1641
|
+
return { ...read, source: 'worktree' };
|
|
1603
1642
|
}
|
|
1604
1643
|
catch (err) {
|
|
1605
1644
|
// The worktree was removed WHILE this request ran (the delivered-worktree sweep, an engine
|
|
1606
1645
|
// reap). Whatever git said — a spawn into the vanished cwd fails ENOENT (the 0.9.1 release
|
|
1607
1646
|
// smoke), a git already running exits 128 "Unable to read current working directory" (the
|
|
1608
1647
|
// 0.9.3 one) — a diff that failed over a worktree that is gone now serves the run branch, as
|
|
1609
|
-
// for a worktree that was already gone. A
|
|
1610
|
-
|
|
1648
|
+
// for a worktree that was already gone. A ref the CALLER named that does not resolve stays the
|
|
1649
|
+
// caller's 400 (codex r1). The `merge-base` literal names no ref: when the worktree is being
|
|
1650
|
+
// swept, its failure is the sweep's, not the caller's (crew#1057).
|
|
1651
|
+
const callersRef = err instanceof UnresolvableDiffBaseError && err.base !== MERGE_BASE_LITERAL;
|
|
1652
|
+
if (!callersRef && !worktreeIntact())
|
|
1611
1653
|
return fromBranchOr409();
|
|
1612
1654
|
return diffError(err);
|
|
1613
1655
|
}
|
|
@@ -3711,12 +3753,14 @@ runtime = {}) {
|
|
|
3711
3753
|
// detection (issue #419) — otherwise a gate served from the durable row after a restart would
|
|
3712
3754
|
// silently drop the warning that the live/replay paths carry.
|
|
3713
3755
|
const refusal = detectRefusal(durableGate.prompt);
|
|
3756
|
+
const gateKind = typeof durableGate['gate_kind'] === 'string' && durableGate['gate_kind'] !== '' ? durableGate['gate_kind'] : undefined;
|
|
3714
3757
|
const entry = {
|
|
3715
3758
|
ord: durableGate.ord ?? 0,
|
|
3716
3759
|
prompt: durableGate.prompt,
|
|
3717
3760
|
lifecycle: 'open',
|
|
3718
3761
|
receivedAt: new Date(durableGate.created_at).toISOString(),
|
|
3719
3762
|
...(refusal !== undefined ? { refusal } : {}),
|
|
3763
|
+
...(gateKind !== undefined ? { gateKind } : {}),
|
|
3720
3764
|
};
|
|
3721
3765
|
gateCache.adopt(id, entry);
|
|
3722
3766
|
return entry;
|
|
@@ -4021,6 +4065,26 @@ runtime = {}) {
|
|
|
4021
4065
|
return reply.code(409).send({ error: message(err) });
|
|
4022
4066
|
}
|
|
4023
4067
|
});
|
|
4068
|
+
/** crew#1058: the runs with a gated reassign (re-seat, then approve) in flight — one at a time. */
|
|
4069
|
+
const gatedReassigns = new Set();
|
|
4070
|
+
/**
|
|
4071
|
+
* codex r1 on #1060: whether approving the run's open gate ACCEPTS the parked unit's work rather
|
|
4072
|
+
* than re-running it (wicked-core `confirm_gate`: a `judge_unavailable` / `qe_reviewer_unavailable`
|
|
4073
|
+
* denial on the cursor unit is judged by the operator; an open `review_adjudication` gate lands
|
|
4074
|
+
* with carried items). Says what the approve does, or `null` when it re-runs the unit.
|
|
4075
|
+
*/
|
|
4076
|
+
async function approveAcceptsWork(run, ord) {
|
|
4077
|
+
const unit = run.units.find((u) => u.ord === ord);
|
|
4078
|
+
const denial = unit?.denial;
|
|
4079
|
+
if (unit?.status === 'rejected' && (denial?.source === 'judge_unavailable' || denial?.source === 'qe_reviewer_unavailable')) {
|
|
4080
|
+
return 'is the operator\'s judgment of the work as it stands (no judge could rule)';
|
|
4081
|
+
}
|
|
4082
|
+
const open = await resolveOpenGate(run.session.id).catch(() => null);
|
|
4083
|
+
if (open !== null && open !== 'no-log' && open.gateKind === 'review_adjudication') {
|
|
4084
|
+
return 'lands the reviewed work with its carried items';
|
|
4085
|
+
}
|
|
4086
|
+
return null;
|
|
4087
|
+
}
|
|
4024
4088
|
// The manual operator lever (crew#442): recycles a run's CURSOR unit via the same
|
|
4025
4089
|
// `adapter.reassignUnit` path the stall-watchdog's automatic escalation uses, for when a
|
|
4026
4090
|
// wedged unit needs recovering and auto-escalation is off, exhausted, or itself failed. Scoped
|
|
@@ -4037,17 +4101,16 @@ runtime = {}) {
|
|
|
4037
4101
|
if (!run)
|
|
4038
4102
|
return reply.code(404).send({ error: 'Run not found' });
|
|
4039
4103
|
// Wave 6 (F-7R2-007 root fix): a run PARKED at a gate on a dead seat — the stall watchdog's
|
|
4040
|
-
// escalation gate, or a verdict escalation —
|
|
4041
|
-
//
|
|
4042
|
-
//
|
|
4043
|
-
//
|
|
4044
|
-
//
|
|
4045
|
-
//
|
|
4046
|
-
//
|
|
4047
|
-
//
|
|
4048
|
-
//
|
|
4049
|
-
// reassign
|
|
4050
|
-
// decision (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
|
|
4104
|
+
// escalation gate, or a verdict escalation — is reassigned and approved in ONE call. crew#1058:
|
|
4105
|
+
// with a named seat it is REASSIGN-THEN-APPROVE. The engine re-seats a parked run's cursor unit
|
|
4106
|
+
// without dispatching it (wicked-core#773, `reseat_parked_unit`, core-ts >= 0.13.0) and refuses
|
|
4107
|
+
// a seat it cannot honour (an author's seat for a review under a distinct-evaluator contract,
|
|
4108
|
+
// a ballot-only seat, a held plan) with the run untouched, so a refusal is a 409 with the gate
|
|
4109
|
+
// still open; the approve then dispatches the unit once, on the new seat. The old order
|
|
4110
|
+
// (approve, then reassign) approved first, so a refused reassign left the failed seat running
|
|
4111
|
+
// again. Without a named seat (the council's pick) the engine needs a running unit to re-route,
|
|
4112
|
+
// so that path stays approve-then-reassign. Audited as BOTH a gate decision
|
|
4113
|
+
// (`via: 'reassign'`) and a reassign, so the "who approved" trail has no side door.
|
|
4051
4114
|
// A steering-author propose gate is NOT approvable this way: its approve LANDS the proposal
|
|
4052
4115
|
// through `POST /runs/:id/gate` (crew#388), and a reassign is not a review of the rules.
|
|
4053
4116
|
const gated = run.session.status === 'awaiting_human';
|
|
@@ -4090,21 +4153,69 @@ runtime = {}) {
|
|
|
4090
4153
|
}
|
|
4091
4154
|
const cursor = resolveCursorUnit(run);
|
|
4092
4155
|
const ord = cursor?.ord ?? run.session.unit_ix;
|
|
4093
|
-
|
|
4094
|
-
if (
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
}
|
|
4102
|
-
}
|
|
4103
|
-
|
|
4156
|
+
const approve = async () => {
|
|
4157
|
+
if (typeof adapter.confirmGate !== 'function')
|
|
4158
|
+
return; // refused above for a gated run
|
|
4159
|
+
const who = actorOf(req);
|
|
4160
|
+
adapter.recordGateActor?.(id, `${who.id} (${who.kind})`);
|
|
4161
|
+
const status = await adapter.confirmGate(id, true);
|
|
4162
|
+
audit.record('gate.decided', actorOf(req), {
|
|
4163
|
+
runId: id,
|
|
4164
|
+
detail: { approve: true, via: 'reassign', status },
|
|
4165
|
+
});
|
|
4166
|
+
};
|
|
4167
|
+
const reassigned = (approved) => {
|
|
4104
4168
|
audit.record('run.reassigned', actorOf(req), {
|
|
4105
4169
|
runId: id,
|
|
4106
|
-
detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved
|
|
4170
|
+
detail: { ord, ...(requestedCli !== undefined ? { cli: requestedCli } : {}), ...(gated ? { approved } : {}) },
|
|
4107
4171
|
});
|
|
4172
|
+
};
|
|
4173
|
+
if (gated && requestedCli !== undefined) {
|
|
4174
|
+
// codex r1 on #1060: an approve that ACCEPTS the parked work instead of re-running the unit —
|
|
4175
|
+
// a `judge_unavailable` / `qe_reviewer_unavailable` denial (the operator judges, the unit
|
|
4176
|
+
// counts) or a `review_adjudication` gate (lands with carried items) — must not follow a
|
|
4177
|
+
// re-seat: the new seat would be recorded on work it never did, and never run.
|
|
4178
|
+
const accepts = await approveAcceptsWork(run, ord);
|
|
4179
|
+
if (accepts !== null) {
|
|
4180
|
+
return reply.code(409).send({
|
|
4181
|
+
error: `run ${id} is parked at a gate whose approve ${accepts}, so a reassign would not run unit ${ord} on '${requestedCli}' — approve, send back or stop it via POST /runs/${id}/gate. Nothing was changed.`,
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
// codex r1 on #1060: the re-seat and the approve are two engine commands; a second reassign of
|
|
4185
|
+
// the same run between them would dispatch a seat neither response names.
|
|
4186
|
+
if (gatedReassigns.has(id)) {
|
|
4187
|
+
return reply.code(409).send({ error: `a reassign of run ${id} is already in flight. Nothing was changed.` });
|
|
4188
|
+
}
|
|
4189
|
+
gatedReassigns.add(id);
|
|
4190
|
+
try {
|
|
4191
|
+
try {
|
|
4192
|
+
await adapter.reassignUnit(id, ord, requestedCli);
|
|
4193
|
+
}
|
|
4194
|
+
catch (err) {
|
|
4195
|
+
// The engine refused the seat with the run untouched: nothing was approved.
|
|
4196
|
+
return reply.code(409).send({ error: `${message(err)}. Nothing was approved; the gate is still open.` });
|
|
4197
|
+
}
|
|
4198
|
+
try {
|
|
4199
|
+
await approve();
|
|
4200
|
+
}
|
|
4201
|
+
catch (err) {
|
|
4202
|
+
reassigned(false);
|
|
4203
|
+
return reply.code(409).send({
|
|
4204
|
+
error: `unit ${ord} was re-seated on ${requestedCli}, but approving the gate failed: ${message(err)} — read the run's gate before answering again`,
|
|
4205
|
+
});
|
|
4206
|
+
}
|
|
4207
|
+
reassigned(true);
|
|
4208
|
+
return reply.send({ status: 'ok', ord, cli: requestedCli, approved: true });
|
|
4209
|
+
}
|
|
4210
|
+
finally {
|
|
4211
|
+
gatedReassigns.delete(id);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
try {
|
|
4215
|
+
if (gated)
|
|
4216
|
+
await approve();
|
|
4217
|
+
await adapter.reassignUnit(id, ord, requestedCli ?? null);
|
|
4218
|
+
reassigned(true);
|
|
4108
4219
|
return reply.send({
|
|
4109
4220
|
status: 'ok',
|
|
4110
4221
|
ord,
|
|
@@ -5768,15 +5879,13 @@ runtime = {}) {
|
|
|
5768
5879
|
// { outcome:'handed_off', payload, landing } — a POLICY proposal, LANDED here as a steering rule.
|
|
5769
5880
|
//
|
|
5770
5881
|
// The policy→steering landing (DES-MEM-FACETED-001 §5.2): estate performs NO rules-write (the
|
|
5771
|
-
// AW-11 invariant) —
|
|
5772
|
-
//
|
|
5773
|
-
//
|
|
5774
|
-
//
|
|
5775
|
-
//
|
|
5776
|
-
//
|
|
5777
|
-
//
|
|
5778
|
-
// backward compatibility. The MEMORY path is UNTOUCHED: a `promoted` outcome returns immediately
|
|
5779
|
-
// with no extra estate call and no landing.
|
|
5882
|
+
// AW-11 invariant) — CREW is the one governed rules-write path. The route reads the pending row
|
|
5883
|
+
// (its `kind_type` carries the `policy:<steering_type>`), shapes a `ConformanceRule` from it,
|
|
5884
|
+
// upserts it via the single-writer actor, and only THEN asks estate to mark the proposal
|
|
5885
|
+
// approved. A landing failure is reported LOUD in-band as `landing.outcome:"failed"` (never a
|
|
5886
|
+
// 500, never the silent no-op crew#388 exists to end) and leaves the proposal PENDING, so it can
|
|
5887
|
+
// be approved again; an approve estate refuses after the rule landed retires the rule. A MEMORY
|
|
5888
|
+
// proposal is passed straight to estate's approve.
|
|
5780
5889
|
app.post(`${V}/proposals/:id/approve`, {
|
|
5781
5890
|
config: {
|
|
5782
5891
|
manifest: {
|
|
@@ -5800,9 +5909,38 @@ runtime = {}) {
|
|
|
5800
5909
|
if (!edit.success) {
|
|
5801
5910
|
return reply.code(400).send(invalidBody(edit.error, 'Invalid approve body'));
|
|
5802
5911
|
}
|
|
5803
|
-
|
|
5912
|
+
// A POLICY proposal accepted with a `reach` (no content) is not a copy: crew lands the rule
|
|
5913
|
+
// itself, so the person's reach rides into the landing below (the policy landing reads it).
|
|
5914
|
+
let policyReach;
|
|
5915
|
+
// The pending read the accept-with-edit makes anyway, done once and shared with it.
|
|
5916
|
+
let editTool = estateTool;
|
|
5917
|
+
if (edit.data.content === undefined && edit.data.reach !== undefined) {
|
|
5918
|
+
let listed;
|
|
5919
|
+
try {
|
|
5920
|
+
listed = (await estateTool('proposal.list', { state: 'pending' }));
|
|
5921
|
+
}
|
|
5922
|
+
catch (err) {
|
|
5923
|
+
return estateUpstreamError(reply, err);
|
|
5924
|
+
}
|
|
5925
|
+
let reused = false;
|
|
5926
|
+
editTool = async (tool, args) => {
|
|
5927
|
+
if (!reused && tool === 'proposal.list' && args['state'] === 'pending') {
|
|
5928
|
+
reused = true;
|
|
5929
|
+
return listed;
|
|
5930
|
+
}
|
|
5931
|
+
return estateTool(tool, args);
|
|
5932
|
+
};
|
|
5933
|
+
const kind = listed.proposals.find((p) => p.id === id)?.kind_type;
|
|
5934
|
+
if (typeof kind === 'string' && kind.startsWith('policy:')) {
|
|
5935
|
+
if (edit.data.reach === 'pattern') {
|
|
5936
|
+
return reply.code(400).send({ error: 'a rule\'s reach is "project" (its project) or "operator" (every project)' });
|
|
5937
|
+
}
|
|
5938
|
+
policyReach = edit.data.reach;
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
if (policyReach === undefined && (edit.data.content !== undefined || edit.data.reach !== undefined)) {
|
|
5804
5942
|
try {
|
|
5805
|
-
const done = await approveEdited(
|
|
5943
|
+
const done = await approveEdited(editTool, id, edit.data, (runId) => projects.index.projectOf(runId));
|
|
5806
5944
|
return reply.code(done.status).send(done.body);
|
|
5807
5945
|
}
|
|
5808
5946
|
catch (err) {
|
|
@@ -5812,6 +5950,18 @@ runtime = {}) {
|
|
|
5812
5950
|
if (editInFlight(id)) {
|
|
5813
5951
|
return reply.code(409).send({ error: `proposal ${id} is already being accepted` });
|
|
5814
5952
|
}
|
|
5953
|
+
// Read the pending row FIRST: a POLICY proposal is landed before estate marks it approved, so
|
|
5954
|
+
// a landing that fails leaves it pending and re-approvable (one decision from the operator's
|
|
5955
|
+
// view: approved WITH its rule, or still pending). The row also carries the `kind_type`
|
|
5956
|
+
// (`policy:<steering_type>`), facets and provenance the approve response does not echo.
|
|
5957
|
+
let row;
|
|
5958
|
+
try {
|
|
5959
|
+
const pending = (await estateTool('proposal.list', { state: 'pending' }));
|
|
5960
|
+
row = pending.proposals.find((p) => p.id === id);
|
|
5961
|
+
}
|
|
5962
|
+
catch (err) {
|
|
5963
|
+
return estateUpstreamError(reply, err);
|
|
5964
|
+
}
|
|
5815
5965
|
// DC-S4a: a proposal THIS ledger filed for a decision lands through `remember()` — the one
|
|
5816
5966
|
// landing path, with the human check inside it (§4.5). A payload naming an id the ledger does
|
|
5817
5967
|
// not know is an ordinary policy proposal (no ORIGIN claim) and takes the path below.
|
|
@@ -5820,24 +5970,22 @@ runtime = {}) {
|
|
|
5820
5970
|
// The ledger links a proposal to its decision only once the `offered` outcome is written; a
|
|
5821
5971
|
// proposal crew filed but has not linked yet (the publish window, or a crash in it) names a
|
|
5822
5972
|
// decision this ledger HOLDS — it must still land through `remember()` (codex on DC-S4a).
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
if (typeof named === 'string' && decisions.deps.ledger.record(named) !== null)
|
|
5827
|
-
decisionId = named;
|
|
5828
|
-
}
|
|
5829
|
-
catch (err) {
|
|
5830
|
-
return estateUpstreamError(reply, err);
|
|
5831
|
-
}
|
|
5973
|
+
const named = row?.payload?.decision?.id;
|
|
5974
|
+
if (typeof named === 'string' && decisions.deps.ledger.record(named) !== null)
|
|
5975
|
+
decisionId = named;
|
|
5832
5976
|
}
|
|
5833
5977
|
if (decisions !== null && decisionId !== null) {
|
|
5834
5978
|
try {
|
|
5835
|
-
const landed = await decisions.remember(decisionId, actorOf(req), {
|
|
5979
|
+
const landed = await decisions.remember(decisionId, actorOf(req), {
|
|
5980
|
+
how: 'needs-you',
|
|
5981
|
+
...(policyReach !== undefined ? { edits: { scope: policyReach === 'operator' ? 'everywhere' : 'project' } } : {}),
|
|
5982
|
+
});
|
|
5836
5983
|
const view = decisions.deps.ledger.view(decisionId);
|
|
5837
5984
|
const landing = {
|
|
5838
5985
|
outcome: 'landed',
|
|
5839
5986
|
ruleId: landed.rule_id,
|
|
5840
5987
|
...(view !== null ? { steering_type: view.edits?.steering_type ?? view.derived.steering_type } : {}),
|
|
5988
|
+
reach: landed.project !== undefined ? 'project' : 'operator',
|
|
5841
5989
|
...(landed.project !== undefined ? { project: landed.project } : {}),
|
|
5842
5990
|
};
|
|
5843
5991
|
return {
|
|
@@ -5852,50 +6000,50 @@ runtime = {}) {
|
|
|
5852
6000
|
throw err;
|
|
5853
6001
|
}
|
|
5854
6002
|
}
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
6003
|
+
// Not a pending POLICY proposal (a memory, a knowledge row, or an id that is not pending, which
|
|
6004
|
+
// estate refuses in its own words): estate decides it — pass through verbatim.
|
|
6005
|
+
if (row === undefined || typeof row.kind_type !== 'string' || !row.kind_type.startsWith('policy:')) {
|
|
6006
|
+
let approved;
|
|
6007
|
+
try {
|
|
6008
|
+
approved = (await estateTool('proposal.approve', { id }));
|
|
6009
|
+
}
|
|
6010
|
+
catch (err) {
|
|
6011
|
+
return estateUpstreamError(reply, err);
|
|
6012
|
+
}
|
|
6013
|
+
if (approved.outcome === 'handed_off') {
|
|
6014
|
+
// Estate handed off a policy this route did not see pending (a row filed between the list
|
|
6015
|
+
// and the approve): it is approved with no rule. Say so; never a silent drop.
|
|
6016
|
+
const error = `estate approved policy proposal ${id} before crew could land its rule; land it by hand via ` +
|
|
6017
|
+
`POST ${V}/governance/rules`;
|
|
6018
|
+
audit.record('governance.steering.landing_failed', actorOf(req), { detail: { proposalId: id, error } });
|
|
6019
|
+
return { outcome: 'handed_off', payload: approved.payload, landing: { outcome: 'failed', error } };
|
|
6020
|
+
}
|
|
5865
6021
|
return approved;
|
|
5866
6022
|
}
|
|
5867
|
-
// POLICY proposal — land the
|
|
6023
|
+
// POLICY proposal — land the rule, THEN approve it in estate.
|
|
6024
|
+
const policyRow = { ...row, kind_type: row.kind_type };
|
|
5868
6025
|
const landFailed = (error) => {
|
|
5869
6026
|
audit.record('governance.steering.landing_failed', actorOf(req), {
|
|
5870
6027
|
detail: { proposalId: id, error },
|
|
5871
6028
|
});
|
|
5872
|
-
|
|
6029
|
+
// Nothing was approved: the proposal is still pending, so the operator can approve it again.
|
|
6030
|
+
return {
|
|
6031
|
+
outcome: 'handed_off',
|
|
6032
|
+
payload: policyRow.payload,
|
|
6033
|
+
landing: { outcome: 'failed', error: `${error} (the proposal is still pending)` },
|
|
6034
|
+
};
|
|
5873
6035
|
};
|
|
5874
6036
|
// A pre-steering engine would SILENTLY DROP `steering_type` and the other steering fields
|
|
5875
6037
|
// (ConformanceRule has no deny_unknown_fields), persisting a rule that enforces differently than
|
|
5876
|
-
// authored — the same guard POST /governance/rules applies. Fail loud
|
|
5877
|
-
// round-trip), persist nothing.
|
|
6038
|
+
// authored — the same guard POST /governance/rules applies. Fail loud, persist nothing.
|
|
5878
6039
|
if (!adapter.steeringSupported()) {
|
|
5879
6040
|
return landFailed(`${new SteeringUnsupportedError('Landing an approved policy proposal').message} — the ` +
|
|
5880
6041
|
`installed engine would silently drop the rule's steering fields; upgrade wicked-core-ts (>= 0.7.5)`);
|
|
5881
6042
|
}
|
|
5882
|
-
//
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
try {
|
|
5887
|
-
const listed = (await estateTool('proposal.list', { state: 'approved' }));
|
|
5888
|
-
proposal = listed.proposals.find((p) => p.id === id);
|
|
5889
|
-
}
|
|
5890
|
-
catch (err) {
|
|
5891
|
-
return landFailed(`the policy proposal was approved but its record could not be re-read to land a steering ` +
|
|
5892
|
-
`rule (${message(err)}); land it by hand via POST ${V}/governance/rules`);
|
|
5893
|
-
}
|
|
5894
|
-
if (proposal === undefined || typeof proposal.kind_type !== 'string') {
|
|
5895
|
-
return landFailed(`the policy proposal was approved but could not be found in the approved queue to recover ` +
|
|
5896
|
-
`its steering type; land it by hand via POST ${V}/governance/rules`);
|
|
5897
|
-
}
|
|
5898
|
-
const built = policyProposalToRule(id, proposal.kind_type, approved.payload, isStringRecord(proposal.facets) ? proposal.facets : undefined);
|
|
6043
|
+
// A proposal with no `project` facet keeps the project of the run that filed it.
|
|
6044
|
+
const runId = policyRow.provenance?.['run_id'];
|
|
6045
|
+
const runProject = typeof runId === 'string' && runId !== '' ? projects.index.projectOf(runId) : undefined;
|
|
6046
|
+
const built = policyProposalToRule(id, policyRow.kind_type, policyRow.payload, isStringRecord(policyRow.facets) ? policyRow.facets : undefined, { ...(runProject !== undefined ? { runProject } : {}), ...(policyReach !== undefined ? { reach: policyReach } : {}) });
|
|
5899
6047
|
if ('error' in built) {
|
|
5900
6048
|
return landFailed(built.error);
|
|
5901
6049
|
}
|
|
@@ -5906,7 +6054,22 @@ runtime = {}) {
|
|
|
5906
6054
|
});
|
|
5907
6055
|
if (landing.outcome === 'failed')
|
|
5908
6056
|
return landFailed(landing.error ?? 'the landing failed');
|
|
5909
|
-
|
|
6057
|
+
// The rule is in the store: now mark the proposal approved. An approve estate does not confirm
|
|
6058
|
+
// is an upstream error and nothing is undone — estate may have committed it, and a concurrent
|
|
6059
|
+
// approve may share the rule. The proposal is then pending with its rule live (the same state a
|
|
6060
|
+
// crash here leaves): approving again re-lands the same rule id and finishes; rejecting it
|
|
6061
|
+
// retires the rule (see the reject route).
|
|
6062
|
+
let approved;
|
|
6063
|
+
try {
|
|
6064
|
+
approved = (await estateTool('proposal.approve', { id }));
|
|
6065
|
+
}
|
|
6066
|
+
catch (err) {
|
|
6067
|
+
audit.record('governance.steering.landing_failed', actorOf(req), {
|
|
6068
|
+
detail: { proposalId: id, ruleId: built.rule.id, error: `rule landed; estate did not confirm the approval: ${message(err)}` },
|
|
6069
|
+
});
|
|
6070
|
+
return estateUpstreamError(reply, err);
|
|
6071
|
+
}
|
|
6072
|
+
return { outcome: 'handed_off', payload: approved.outcome === 'handed_off' ? approved.payload : policyRow.payload, landing };
|
|
5910
6073
|
});
|
|
5911
6074
|
// POST /proposals/:id/reject → proposal.reject → { ok: true }.
|
|
5912
6075
|
app.post(`${V}/proposals/:id/reject`,
|
|
@@ -5932,12 +6095,19 @@ runtime = {}) {
|
|
|
5932
6095
|
throw err;
|
|
5933
6096
|
}
|
|
5934
6097
|
}
|
|
6098
|
+
let rejected;
|
|
5935
6099
|
try {
|
|
5936
|
-
|
|
6100
|
+
rejected = (await estateTool('proposal.reject', { id }));
|
|
5937
6101
|
}
|
|
5938
6102
|
catch (err) {
|
|
5939
6103
|
return estateUpstreamError(reply, err);
|
|
5940
6104
|
}
|
|
6105
|
+
// A policy proposal's rule lands before its approval, so a still-pending proposal may already
|
|
6106
|
+
// have a live rule (a crash or an unconfirmed approve in between): rejecting it withdraws it.
|
|
6107
|
+
const withdrawn = await withdrawLandedRule(adapter, id.trim());
|
|
6108
|
+
if (withdrawn !== null)
|
|
6109
|
+
return reply.code(502).send({ error: withdrawn });
|
|
6110
|
+
return rejected;
|
|
5941
6111
|
});
|
|
5942
6112
|
// ── Memory management (governed-knowledge surface, DES-MEM-FACETED-001) ───────
|
|
5943
6113
|
// Browse + retire the EXISTING operator memory store — the studio counterpart to /proposals:
|
|
@@ -6146,6 +6316,175 @@ runtime = {}) {
|
|
|
6146
6316
|
return estateUpstreamError(reply, err);
|
|
6147
6317
|
}
|
|
6148
6318
|
});
|
|
6319
|
+
// POST /memory/edit { memory_id, content } → memory.list (find it) → memory.capture (the new words,
|
|
6320
|
+
// the old tier/scope/facets) → memory.erase { id: old } → { memory_id: new, retired: old }.
|
|
6321
|
+
// estate has no in-place update and no transaction across tools, so an edit is a replacement made in
|
|
6322
|
+
// steps. Erase-by-id is probed BEFORE anything is written (an id no memory has, so nothing is deleted):
|
|
6323
|
+
// an estate without it answers 501 with nothing changed. After a write, any step that does not answer
|
|
6324
|
+
// as expected is reconciled by re-reading the store, never assumed: a replacement that landed is a
|
|
6325
|
+
// 200; a new copy beside a surviving old memory is taken back out (and checked); whatever remains is
|
|
6326
|
+
// named in the 502.
|
|
6327
|
+
app.post(`${V}/memory/edit`, {
|
|
6328
|
+
config: {
|
|
6329
|
+
manifest: {
|
|
6330
|
+
requestType: 'EditMemoryBody',
|
|
6331
|
+
responseType: 'EditMemoryResponse',
|
|
6332
|
+
statusCodes: [200, 400, 404, 409, 501, 502],
|
|
6333
|
+
},
|
|
6334
|
+
},
|
|
6335
|
+
}, async (req, reply) => {
|
|
6336
|
+
const parsed = EditMemorySchema.safeParse(req.body);
|
|
6337
|
+
if (!parsed.success) {
|
|
6338
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid edit request'));
|
|
6339
|
+
}
|
|
6340
|
+
const memoryId = parsed.data.memory_id.trim();
|
|
6341
|
+
const content = parsed.data.content.trim();
|
|
6342
|
+
if (memoryId === '')
|
|
6343
|
+
return reply.code(400).send({ error: '`memory_id` must not be empty' });
|
|
6344
|
+
if (content === '')
|
|
6345
|
+
return reply.code(400).send({ error: '`content` must not be empty' });
|
|
6346
|
+
if (memoryEditsInFlight.has(memoryId)) {
|
|
6347
|
+
return reply.code(409).send({ error: `memory ${memoryId} is already being edited` });
|
|
6348
|
+
}
|
|
6349
|
+
memoryEditsInFlight.add(memoryId);
|
|
6350
|
+
// Every estate call here is crew's own (no client argument reaches estate unchecked), so an
|
|
6351
|
+
// estate refusal is an upstream fault: 502, never the 400 `estateUpstreamError` gives -32602.
|
|
6352
|
+
const upstream = (what, err) => reply.code(502).send({ error: `${what}: ${message(err)}` });
|
|
6353
|
+
const listAll = async () => {
|
|
6354
|
+
const raw = await estateTool('memory.list', {});
|
|
6355
|
+
const items = typeof raw === 'object' && raw !== null ? raw['items'] : undefined;
|
|
6356
|
+
if (!Array.isArray(items))
|
|
6357
|
+
throw new EstateMcpError('memory.list returned an unexpected shape');
|
|
6358
|
+
return items.map(shapeMemoryItem);
|
|
6359
|
+
};
|
|
6360
|
+
const deletedCount = (raw) => (typeof raw === 'object' && raw !== null ? raw['deleted_count'] : undefined);
|
|
6361
|
+
try {
|
|
6362
|
+
// 1. The memory as it is: its tier, scope and facets ride onto the replacement.
|
|
6363
|
+
let before;
|
|
6364
|
+
try {
|
|
6365
|
+
before = await listAll();
|
|
6366
|
+
}
|
|
6367
|
+
catch (err) {
|
|
6368
|
+
return upstream('reading the memory store failed; nothing changed', err);
|
|
6369
|
+
}
|
|
6370
|
+
const old = before.find((m) => m.id === memoryId);
|
|
6371
|
+
if (old === undefined)
|
|
6372
|
+
return reply.code(404).send({ error: `no memory ${memoryId}` });
|
|
6373
|
+
if (old.content.trim() === content) {
|
|
6374
|
+
return reply.code(400).send({ error: 'the new words are the same as the memory\'s; nothing to change' });
|
|
6375
|
+
}
|
|
6376
|
+
// 2. Erase-by-id must exist before anything is written (wicked-estate#188). The probe names an
|
|
6377
|
+
// id no memory carries, so it deletes nothing.
|
|
6378
|
+
try {
|
|
6379
|
+
await estateTool('memory.erase', { id: MEMORY_EDIT_PROBE_ID });
|
|
6380
|
+
}
|
|
6381
|
+
catch (err) {
|
|
6382
|
+
if (err instanceof EstateMcpError && err.code === -32602 && ESTATE_NO_ERASE_BY_ID.test(err.message)) {
|
|
6383
|
+
return reply.code(501).send({
|
|
6384
|
+
error: 'this wicked-estate cannot erase one memory by id (it needs wicked-estate#188), so it cannot replace one; nothing changed',
|
|
6385
|
+
code: 'estate_upgrade_required',
|
|
6386
|
+
});
|
|
6387
|
+
}
|
|
6388
|
+
return upstream('checking that the store can erase one memory failed; nothing changed', err);
|
|
6389
|
+
}
|
|
6390
|
+
// 3. The new words, as a new memory in the old one's place.
|
|
6391
|
+
const captureArgs = { content, scope: old.scope, facets: old.facets };
|
|
6392
|
+
if (old.tier !== '') {
|
|
6393
|
+
captureArgs['tier'] = old.tier;
|
|
6394
|
+
const kind = KIND_FOR_TIER[old.tier];
|
|
6395
|
+
if (kind !== undefined)
|
|
6396
|
+
captureArgs['kind'] = kind;
|
|
6397
|
+
}
|
|
6398
|
+
const beforeIds = new Set(before.map((m) => m.id));
|
|
6399
|
+
let newId = null;
|
|
6400
|
+
let captureErr = null;
|
|
6401
|
+
try {
|
|
6402
|
+
const captured = await estateTool('memory.capture', captureArgs);
|
|
6403
|
+
const id = typeof captured === 'object' && captured !== null ? captured['memory_id'] : undefined;
|
|
6404
|
+
if (typeof id === 'string' && id !== '')
|
|
6405
|
+
newId = id;
|
|
6406
|
+
else
|
|
6407
|
+
captureErr = new EstateMcpError('memory.capture returned no memory_id');
|
|
6408
|
+
}
|
|
6409
|
+
catch (err) {
|
|
6410
|
+
captureErr = err;
|
|
6411
|
+
}
|
|
6412
|
+
if (newId === null) {
|
|
6413
|
+
// The capture may still have written (a lost answer): look before saying nothing changed.
|
|
6414
|
+
let now;
|
|
6415
|
+
try {
|
|
6416
|
+
now = await listAll();
|
|
6417
|
+
}
|
|
6418
|
+
catch (err) {
|
|
6419
|
+
return reply.code(502).send({
|
|
6420
|
+
error: `writing the new words did not answer as expected (${message(captureErr)}) and the store could not be re-read (${message(err)}); a copy of the new words may exist beside ${memoryId}`,
|
|
6421
|
+
});
|
|
6422
|
+
}
|
|
6423
|
+
const copies = now.filter((m) => !beforeIds.has(m.id) && m.content.trim() === content && m.scope === old.scope);
|
|
6424
|
+
if (copies.length !== 1) {
|
|
6425
|
+
return reply.code(502).send({
|
|
6426
|
+
error: copies.length === 0
|
|
6427
|
+
? `writing the new words failed (${message(captureErr)}); nothing changed`
|
|
6428
|
+
: `writing the new words did not answer as expected (${message(captureErr)}); ${copies.length} copies of them now exist beside ${memoryId}: ${copies.map((m) => m.id).join(', ')}`,
|
|
6429
|
+
});
|
|
6430
|
+
}
|
|
6431
|
+
newId = copies[0].id;
|
|
6432
|
+
}
|
|
6433
|
+
// 4. The old memory out.
|
|
6434
|
+
let eraseErr = null;
|
|
6435
|
+
let deleted;
|
|
6436
|
+
try {
|
|
6437
|
+
deleted = deletedCount(await estateTool('memory.erase', { id: memoryId }));
|
|
6438
|
+
}
|
|
6439
|
+
catch (err) {
|
|
6440
|
+
eraseErr = err;
|
|
6441
|
+
}
|
|
6442
|
+
if (eraseErr === null && deleted === 1) {
|
|
6443
|
+
const body = { memory_id: newId, retired: memoryId };
|
|
6444
|
+
return body;
|
|
6445
|
+
}
|
|
6446
|
+
const why = eraseErr !== null ? `retiring ${memoryId} failed (${message(eraseErr)})` : `retiring ${memoryId} removed ${String(deleted)} memories, expected 1`;
|
|
6447
|
+
// 5. Reconcile: what is in the store now decides the answer, never the failed call.
|
|
6448
|
+
const present = async () => new Set((await listAll()).map((m) => m.id));
|
|
6449
|
+
let ids;
|
|
6450
|
+
try {
|
|
6451
|
+
ids = await present();
|
|
6452
|
+
}
|
|
6453
|
+
catch (err) {
|
|
6454
|
+
return reply.code(502).send({ error: `${why}, and the store could not be re-read (${message(err)}); ${newId} was written and ${memoryId} may remain` });
|
|
6455
|
+
}
|
|
6456
|
+
if (!ids.has(memoryId) && ids.has(newId)) {
|
|
6457
|
+
// The retire landed even though its answer did not: the edit is complete.
|
|
6458
|
+
const body = { memory_id: newId, retired: memoryId };
|
|
6459
|
+
return body;
|
|
6460
|
+
}
|
|
6461
|
+
if (!ids.has(newId)) {
|
|
6462
|
+
return reply.code(502).send({ error: ids.has(memoryId) ? `${why}; nothing changed` : `${why}; neither ${memoryId} nor ${newId} is in the store now` });
|
|
6463
|
+
}
|
|
6464
|
+
// Both are there: take the new copy back out, then check that it is gone.
|
|
6465
|
+
try {
|
|
6466
|
+
await estateTool('memory.erase', { id: newId });
|
|
6467
|
+
}
|
|
6468
|
+
catch {
|
|
6469
|
+
// Checked below: the store, not the call, says whether it is gone.
|
|
6470
|
+
}
|
|
6471
|
+
try {
|
|
6472
|
+
ids = await present();
|
|
6473
|
+
}
|
|
6474
|
+
catch (err) {
|
|
6475
|
+
return reply.code(502).send({ error: `${why}; taking ${newId} back out could not be checked (${message(err)}), so both ${memoryId} and ${newId} may remain` });
|
|
6476
|
+
}
|
|
6477
|
+
return reply.code(502).send({
|
|
6478
|
+
error: ids.has(newId) ? `${why}; taking ${newId} back out failed, so both ${memoryId} and ${newId} remain` : `${why}; the new copy was taken back out, so nothing changed`,
|
|
6479
|
+
});
|
|
6480
|
+
}
|
|
6481
|
+
catch (err) {
|
|
6482
|
+
return upstream('editing the memory failed', err);
|
|
6483
|
+
}
|
|
6484
|
+
finally {
|
|
6485
|
+
memoryEditsInFlight.delete(memoryId);
|
|
6486
|
+
}
|
|
6487
|
+
});
|
|
6149
6488
|
// ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
|
|
6150
6489
|
registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
|
|
6151
6490
|
// crew#371: a project's requirements / domain / coverage, folded over its repos' own reads.
|