@corelayer-ai/cli 0.5.2 → 0.5.3
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 -16
- package/dist/commands/issue-tickets.js +163 -0
- package/dist/commands/issues.js +15 -0
- package/dist/commands/preflight.js +4 -0
- package/dist/lib/api-client.js +13 -4
- package/package.json +2 -2
- package/skill/SKILL.md +1 -1
- package/skill/references/ecs-monitor-recovery.md +67 -0
- package/skill/references/preflight.md +2 -0
package/README.md
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
# CLI
|
|
1
|
+
# Corelayer CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
User-owned API keys require a current organization membership on every authentication. Their effective role cannot exceed either the key's stored role or the owner's current role. Removing a user blocks their keys once the membership change reaches Corelayer; demoting an admin immediately limits their keys after synchronization. This also applies to user-owned automation keys. Keys with no user owner retain their existing service-key behavior and require explicit revocation/expiry management.
|
|
6
|
-
|
|
7
|
-
`corelayer logout` revokes the credential saved in `~/.corelayer/config.json` at its saved API URL before clearing the file. A failed revocation leaves the credential available for retry. `corelayer logout --local-only` explicitly removes the local copy without revoking the remote credential, for offline cleanup or an already expired/revoked key. A key in `CORELAYER_API_KEY` is not revoked by logout; unset that variable to stop using it and revoke it through key management when needed. Revoking a shared saved API key also affects other consumers of that same key.
|
|
8
|
-
|
|
9
|
-
`corelayer config get` and its JSON output omit the token. Preflight invokes Git without a shell, resolves refs to commit IDs, and disables external diff/text-conversion helpers.
|
|
10
|
-
|
|
11
|
-
## Deploying the fix
|
|
12
|
-
|
|
13
|
-
Deploy the platform database service and API server changes before distributing the updated CLI: logout requires the new `cli-auth.logout` operation and `/api/v1/cli/logout` endpoint. No schema migration is required.
|
|
14
|
-
|
|
15
|
-
Existing CLI keys do not retroactively acquire the new 24-hour expiry. Revoke those old keys through key management and have users log in again. Live membership/role enforcement applies to existing user-owned keys once the server change is deployed. For incident response, explicitly revoke affected keys rather than relying only on local logout or identity-provider synchronization.
|
|
16
|
-
|
|
17
|
-
These changes address the audited defects. They do not introduce OAuth PKCE, granular read-only/resource scopes, OS-keychain storage, npm provenance, or enterprise SSO-policy configuration; those remain separate enterprise-readiness work.
|
|
3
|
+
Source and packaging for the `@corelayer-ai/cli` command-line client. Provides authentication, production issue investigation and actions, organization memory queries, schedules, preflight checks, and MCP integration. The `skill/` folder contains the agent instructions distributed with the CLI.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFlag } from "../lib/args.js";
|
|
3
|
+
import { fail, printJson } from "../lib/output.js";
|
|
4
|
+
function ticketInput(args, config) {
|
|
5
|
+
const provider = readFlag(args, "--provider");
|
|
6
|
+
if (provider !== "jira" && provider !== "linear")
|
|
7
|
+
fail("--provider jira|linear is required.");
|
|
8
|
+
const groupId = readFlag(args, "--group") || config.defaults?.group;
|
|
9
|
+
if (!groupId)
|
|
10
|
+
fail("--group is required (or configure default-group).");
|
|
11
|
+
const input = { groupId, provider };
|
|
12
|
+
for (const [flag, key] of [
|
|
13
|
+
["--account", "accountId"],
|
|
14
|
+
["--project", "projectId"],
|
|
15
|
+
["--team", "teamId"],
|
|
16
|
+
["--type", "issueTypeId"],
|
|
17
|
+
["--query", "query"],
|
|
18
|
+
["--ticket", "ticketId"],
|
|
19
|
+
]) {
|
|
20
|
+
const value = readFlag(args, flag);
|
|
21
|
+
if (value)
|
|
22
|
+
input[key] = value;
|
|
23
|
+
}
|
|
24
|
+
const revision = readFlag(args, "--revision");
|
|
25
|
+
if (revision) {
|
|
26
|
+
const value = Number(revision);
|
|
27
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
28
|
+
fail("--revision must be a positive integer from ticket destinations.");
|
|
29
|
+
input.expectedRevision = value;
|
|
30
|
+
}
|
|
31
|
+
const fields = readFlag(args, "--fields");
|
|
32
|
+
if (fields) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fields);
|
|
35
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
|
|
36
|
+
fail("--fields must be a JSON object.");
|
|
37
|
+
input.fields = parsed;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
fail("--fields must be a JSON object.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
function ticketIdentity(args, mutating) {
|
|
46
|
+
const suppliedId = readFlag(args, "--request-id");
|
|
47
|
+
const requestId = suppliedId || (mutating ? randomUUID() : undefined);
|
|
48
|
+
if ((mutating || suppliedId !== undefined) &&
|
|
49
|
+
(!requestId ||
|
|
50
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)))
|
|
51
|
+
fail("A valid --request-id UUID is required.");
|
|
52
|
+
return requestId;
|
|
53
|
+
}
|
|
54
|
+
function mutationInput(action, input, requestId) {
|
|
55
|
+
if (!input.accountId)
|
|
56
|
+
fail("--account is required; use ticket destinations to discover accounts.");
|
|
57
|
+
if (action === "link" && !input.ticketId)
|
|
58
|
+
fail("--ticket is required for link.");
|
|
59
|
+
input.requestId = requestId;
|
|
60
|
+
input.intent =
|
|
61
|
+
action === "create"
|
|
62
|
+
? "create_new"
|
|
63
|
+
: action === "link"
|
|
64
|
+
? "link_existing"
|
|
65
|
+
: "find_or_create";
|
|
66
|
+
process.stderr.write(`Ticket request: ${requestId}\n`);
|
|
67
|
+
}
|
|
68
|
+
function interruptedRequest(error) {
|
|
69
|
+
return (error instanceof Error &&
|
|
70
|
+
(/timed out|fetch failed|socket|network/i.test(error.message) ||
|
|
71
|
+
("status" in error &&
|
|
72
|
+
typeof error.status === "number" &&
|
|
73
|
+
error.status >= 500)));
|
|
74
|
+
}
|
|
75
|
+
function resultExitCode(status) {
|
|
76
|
+
const codes = {
|
|
77
|
+
needs_input: 2,
|
|
78
|
+
pending: 3,
|
|
79
|
+
in_progress: 3,
|
|
80
|
+
recovery_required: 4,
|
|
81
|
+
blocked: 4,
|
|
82
|
+
stale_request: 2,
|
|
83
|
+
failed: 1,
|
|
84
|
+
error: 1,
|
|
85
|
+
};
|
|
86
|
+
return typeof status === "string" ? codes[status] || 0 : 0;
|
|
87
|
+
}
|
|
88
|
+
function shellArgument(value) {
|
|
89
|
+
return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
|
|
90
|
+
}
|
|
91
|
+
function resumeFlags(input, requestId) {
|
|
92
|
+
const flags = [
|
|
93
|
+
["groupId", "--group"],
|
|
94
|
+
["provider", "--provider"],
|
|
95
|
+
["accountId", "--account"],
|
|
96
|
+
["projectId", "--project"],
|
|
97
|
+
["teamId", "--team"],
|
|
98
|
+
["issueTypeId", "--type"],
|
|
99
|
+
["expectedRevision", "--revision"],
|
|
100
|
+
];
|
|
101
|
+
const values = flags
|
|
102
|
+
.filter(([key]) => input[key] !== undefined)
|
|
103
|
+
.map(([key, flag]) => `${flag} ${shellArgument(input[key])}`);
|
|
104
|
+
if (input.fields)
|
|
105
|
+
values.push(`--fields ${shellArgument(JSON.stringify(input.fields))}`);
|
|
106
|
+
values.push(`--request-id ${shellArgument(requestId)}`);
|
|
107
|
+
return values.join(" ");
|
|
108
|
+
}
|
|
109
|
+
function humanTicketResult(result, issueId, input, requestId) {
|
|
110
|
+
const lines = [
|
|
111
|
+
String(result.message || result.status || JSON.stringify(result, null, 2)),
|
|
112
|
+
];
|
|
113
|
+
const candidates = Array.isArray(result.candidates)
|
|
114
|
+
? result.candidates.filter((candidate) => candidate !== null && typeof candidate === "object")
|
|
115
|
+
: [];
|
|
116
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
117
|
+
lines.push(`${index + 1}. ${candidate.identifier || candidate.id}: ${candidate.title || ""}${candidate.evidence ? ` — ${candidate.evidence}` : ""}`);
|
|
118
|
+
if (candidate.url)
|
|
119
|
+
lines.push(` ${candidate.url}`);
|
|
120
|
+
}
|
|
121
|
+
const resumeId = typeof result.requestId === "string" ? result.requestId : requestId;
|
|
122
|
+
if (result.status === "needs_input" && resumeId) {
|
|
123
|
+
const flags = resumeFlags(input, resumeId);
|
|
124
|
+
const firstTicket = candidates[0]?.id;
|
|
125
|
+
if (firstTicket)
|
|
126
|
+
lines.push(`Link an existing ticket: corelayer issues ticket link ${shellArgument(issueId)} ${flags} --ticket ${shellArgument(firstTicket)}`);
|
|
127
|
+
lines.push(`Create a new ticket: corelayer issues ticket create ${shellArgument(issueId)} ${flags}`);
|
|
128
|
+
if (result.requirements)
|
|
129
|
+
lines.push(`Required ticket details: ${JSON.stringify(result.requirements, null, 2)}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
export async function runIssueTickets(args, client, config, ctx) {
|
|
134
|
+
const [action, issueId] = args;
|
|
135
|
+
if (!issueId ||
|
|
136
|
+
!["destinations", "search", "ensure", "create", "link", "status"].includes(action))
|
|
137
|
+
fail("Usage: corelayer issues ticket <destinations|search|ensure|create|link|status> <id|slug> --provider jira|linear --group <id>");
|
|
138
|
+
const input = ticketInput(args, config);
|
|
139
|
+
const mutating = ["ensure", "create", "link"].includes(action);
|
|
140
|
+
const requestId = ticketIdentity(args, mutating);
|
|
141
|
+
if (mutating)
|
|
142
|
+
mutationInput(action, input, requestId);
|
|
143
|
+
const suffixes = {
|
|
144
|
+
destinations: "/destinations",
|
|
145
|
+
search: "/search",
|
|
146
|
+
status: `/requests/${encodeURIComponent(requestId || "current")}`,
|
|
147
|
+
};
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await client.ticketRequest(mutating || action === "search" ? "POST" : "GET", issueId, suffixes[action] || "", input);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!mutating || !interruptedRequest(error))
|
|
154
|
+
throw error;
|
|
155
|
+
process.stderr.write("Response interrupted; checking the same ticket request.\n");
|
|
156
|
+
result = await client.ticketRequest("GET", issueId, `/requests/${encodeURIComponent(requestId)}`, { groupId: input.groupId, provider: input.provider });
|
|
157
|
+
}
|
|
158
|
+
if (ctx.json)
|
|
159
|
+
printJson(result);
|
|
160
|
+
else
|
|
161
|
+
process.stdout.write(`${humanTicketResult(result, issueId, input, requestId)}\n`);
|
|
162
|
+
process.exitCode = resultExitCode(result.status);
|
|
163
|
+
}
|
package/dist/commands/issues.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CorelayerClient } from "../lib/api-client.js";
|
|
|
4
4
|
import { readFlag } from "../lib/args.js";
|
|
5
5
|
import { readConfig, resolveApiUrl, resolveToken } from "../lib/config.js";
|
|
6
6
|
import { fail, formatDate, printJson, printTable } from "../lib/output.js";
|
|
7
|
+
import { runIssueTickets } from "./issue-tickets.js";
|
|
7
8
|
function hasFlag(args, flag) {
|
|
8
9
|
return args.includes(flag);
|
|
9
10
|
}
|
|
@@ -53,6 +54,7 @@ USAGE:
|
|
|
53
54
|
SUBCOMMANDS:
|
|
54
55
|
list List issues in a group
|
|
55
56
|
get <id|slug> Show details for one issue
|
|
57
|
+
ticket <action> <id> Jira/Linear destinations, search, ensure, create, link, status
|
|
56
58
|
summary Summarize issue health for a group
|
|
57
59
|
diff Show issues new or changed since a timestamp
|
|
58
60
|
close <id|slug> Close an issue (write operation)
|
|
@@ -75,6 +77,15 @@ OPTIONS:
|
|
|
75
77
|
Diff: ISO 8601 timestamp or preset (1hour, 24hours, 7days, 14days, 30days)
|
|
76
78
|
--duration <duration>
|
|
77
79
|
Diff: human-friendly duration (e.g. 2hours, 4hours, 30m, 1d)
|
|
80
|
+
--provider jira|linear Ticket provider
|
|
81
|
+
--account <id> Connected ticket account
|
|
82
|
+
--project <id> Ticket project
|
|
83
|
+
--team <id> Linear team
|
|
84
|
+
--type <id> Jira issue type
|
|
85
|
+
--ticket <id> Selected existing ticket (link)
|
|
86
|
+
--request-id <uuid> Resume the same mutation or read its status
|
|
87
|
+
--revision <n> Jira account revision from ticket destinations
|
|
88
|
+
--fields <json> Required provider creation fields
|
|
78
89
|
--yes Skip confirmation prompt (delete)
|
|
79
90
|
--json Output as JSON
|
|
80
91
|
-h, --help Print help
|
|
@@ -299,6 +310,10 @@ export async function runIssues(args, ctx) {
|
|
|
299
310
|
}
|
|
300
311
|
const apiUrl = resolveApiUrl(ctx.apiUrlOverride);
|
|
301
312
|
const client = new CorelayerClient(apiUrl, token);
|
|
313
|
+
if (sub === "ticket") {
|
|
314
|
+
await runIssueTickets(args.slice(1), client, config, ctx);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
302
317
|
const commandArgs = removeFlags(args.slice(1));
|
|
303
318
|
const handler = ISSUE_COMMAND_HANDLERS[sub];
|
|
304
319
|
if (!handler) {
|
|
@@ -129,6 +129,10 @@ function buildPreflightContext(args) {
|
|
|
129
129
|
return { base, description, repo, title };
|
|
130
130
|
}
|
|
131
131
|
function printPreflightResult(result, ctx) {
|
|
132
|
+
if (result.degraded) {
|
|
133
|
+
process.stderr.write(`⚠ Preflight could not run (${result.reason ?? "unknown reason"}). This is not an all-clear.\n`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
132
136
|
if (result.checks.length === 0) {
|
|
133
137
|
if (!ctx.quiet) {
|
|
134
138
|
process.stdout.write("✓ Preflight: nothing in org memory to double-check for this change.\n");
|
package/dist/lib/api-client.js
CHANGED
|
@@ -25,9 +25,9 @@ export class CorelayerClient {
|
|
|
25
25
|
this.token = token;
|
|
26
26
|
this.anomalies = new AnomalyApiClient((method, path) => this.request(method, path));
|
|
27
27
|
}
|
|
28
|
-
async request(method, path, body) {
|
|
28
|
+
async request(method, path, body, timeoutMs = 30_000) {
|
|
29
29
|
const controller = new AbortController();
|
|
30
|
-
const timer = setTimeout(() => controller.abort(),
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
31
|
let res;
|
|
32
32
|
try {
|
|
33
33
|
res = await fetch(`${this.baseUrl}${path}`, {
|
|
@@ -51,10 +51,17 @@ export class CorelayerClient {
|
|
|
51
51
|
clearTimeout(timer);
|
|
52
52
|
}
|
|
53
53
|
if (!res.ok) {
|
|
54
|
-
throw new Error(await parseError(res))
|
|
54
|
+
throw Object.assign(new Error(await parseError(res)), {
|
|
55
|
+
status: res.status,
|
|
56
|
+
});
|
|
55
57
|
}
|
|
56
58
|
return (await res.json());
|
|
57
59
|
}
|
|
60
|
+
async ticketRequest(method, issueId, action, input) {
|
|
61
|
+
const base = `/api/v1/issues/${encodeURIComponent(issueId)}/tickets${action}`;
|
|
62
|
+
const query = new URLSearchParams(Object.entries(input).map(([key, value]) => [key, String(value)]));
|
|
63
|
+
return this.request(method, method === "GET" ? `${base}?${query}` : base, method === "POST" ? input : undefined, 120_000);
|
|
64
|
+
}
|
|
58
65
|
async listIssues(groupId, filters) {
|
|
59
66
|
const params = new URLSearchParams({ groupId });
|
|
60
67
|
for (const [key, value] of Object.entries(filters)) {
|
|
@@ -196,7 +203,9 @@ export async function exchangeCliCode(baseUrl, code) {
|
|
|
196
203
|
clearTimeout(timer);
|
|
197
204
|
}
|
|
198
205
|
if (!res.ok) {
|
|
199
|
-
throw new Error(await parseError(res))
|
|
206
|
+
throw Object.assign(new Error(await parseError(res)), {
|
|
207
|
+
status: res.status,
|
|
208
|
+
});
|
|
200
209
|
}
|
|
201
210
|
return (await res.json());
|
|
202
211
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@corelayer-ai/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Corelayer CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@corelayer-ai/sdk": "^0.2.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^
|
|
25
|
+
"@types/node": "^24.19.2",
|
|
26
26
|
"tsx": "^4.22.4",
|
|
27
27
|
"typescript": "^5.8.3"
|
|
28
28
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -22,6 +22,7 @@ Corelayer is an AI-native production support platform. Use it to inspect live is
|
|
|
22
22
|
- **[Production health](#production-health)** — Summarize and list current issues
|
|
23
23
|
- **[Issue investigation](#issue-investigation)** — Read evidence, root cause, and next steps
|
|
24
24
|
- **[Post-deploy triage](#post-deploy-triage)** — Find issues new or changed after a release
|
|
25
|
+
- **[ECS monitoring recovery](references/ecs-monitor-recovery.md)** — Preview and explicitly acknowledge skipped coverage in a validated AWS tenant runtime
|
|
25
26
|
- **[Preflight](#preflight-before-a-pull-request)** — Check a change against the organization's memory
|
|
26
27
|
- **[Issue actions](#issue-actions)** — Close, reopen, bulk-close, or delete with approval
|
|
27
28
|
- **[MCP server](#mcp-server)** — Give agents Corelayer tools directly
|
|
@@ -303,7 +304,6 @@ Remote MCP is read-only. It exposes:
|
|
|
303
304
|
- `corelayer.get_issue`
|
|
304
305
|
- `corelayer.get_issue_summary`
|
|
305
306
|
- `corelayer.list_integrations`
|
|
306
|
-
- `corelayer.search_org_memory`
|
|
307
307
|
- `corelayer.preflight`
|
|
308
308
|
|
|
309
309
|
Call `corelayer.list_groups` first and pass the selected `id` as `groupId` to group-scoped tools.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# ECS monitoring cursor recovery
|
|
2
|
+
|
|
3
|
+
Use this procedure only when an operator explicitly asks to recover an expired ECS monitoring cursor.
|
|
4
|
+
|
|
5
|
+
Supported scope: an enabled, validated tenant runtime with both deployment and organization binding, AWS data-plane mode, and the tenant monitoring table. The legacy `infra/environments/customer-template` ECS deployment has no such binding and is deliberately unsupported by this command. Do not enable global tenant mode, copy credentials, or broaden its `__system__` policy to bypass that boundary. A caller rejected as unbound needs the supported tenant deployment contract before this procedure applies.
|
|
6
|
+
|
|
7
|
+
ECS stopped-task history is finite. Normal polling refuses a cursor outside its
|
|
8
|
+
supported history window rather than silently skipping evidence. Fix the original
|
|
9
|
+
polling failure before recovery. CloudWatch log-only cursors can catch up normally;
|
|
10
|
+
this tool is only for an active monitored AWS ECS service in an enabled customer
|
|
11
|
+
runtime using AWS DynamoDB.
|
|
12
|
+
|
|
13
|
+
The release includes `dist/bin/recover-ecs-monitor-cursor.js` in the engine's
|
|
14
|
+
`controls` container. An authorized operator runs it through the installation's
|
|
15
|
+
normal private Kubernetes access. It uses the runtime's organization, deployment,
|
|
16
|
+
region, table and AWS identity; it accepts no scope or endpoint overrides.
|
|
17
|
+
|
|
18
|
+
Preview without writing:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
kubectl -n corelayer exec deploy/engine -c controls -- \
|
|
22
|
+
node dist/bin/recover-ecs-monitor-cursor.js --resource RESOURCE_UUID \
|
|
23
|
+
--reason 'Resume after fixing the monitoring outage'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Review the returned `scope`, `intent` and `skippedInterval`. The interval
|
|
27
|
+
`[expectedCursor, resumeAt)` has **unverified coverage**. Acknowledging it does not
|
|
28
|
+
recover historical events or mark them processed. Review any independent retained
|
|
29
|
+
logs and incident evidence separately. The preview is not a successful recovery.
|
|
30
|
+
|
|
31
|
+
Apply the exact reviewed intent, replacing every placeholder with the preview's
|
|
32
|
+
value. Approval expires after ten minutes; the resume point must still be within
|
|
33
|
+
the last fifty minutes and must not be in the future.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
kubectl -n corelayer exec deploy/engine -c controls -- \
|
|
37
|
+
node dist/bin/recover-ecs-monitor-cursor.js --resource RESOURCE_UUID \
|
|
38
|
+
--expected-cursor EXPECTED_MS --resume-at RESUME_MS \
|
|
39
|
+
--request-id REQUEST_UUID --approved-at APPROVED_MS \
|
|
40
|
+
--reason 'Resume after fixing the monitoring outage' --apply --acknowledge-gap
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
One transaction compares the stored cursor to the reviewed value, confirms that no migration hold exists, advances the cursor and creates an immutable recovery audit record in the same customer table. The audit retains `ApprovedAt`; this recovery write uses that same approved intent time for the cursor's `UpdatedAt`. It does not claim a server commit time. No fresh wall-clock value enters the transaction, so identical retries retain both the token and transaction parameters while the first attempt is pending. Normal
|
|
44
|
+
polling cannot erase that separate record. The operation affects only the selected
|
|
45
|
+
normal ECS cursor; anomaly cursors are unchanged. Concurrent progress or recovery
|
|
46
|
+
causes the conditional write to fail without discarding the newer cursor. Obtain
|
|
47
|
+
a fresh preview if the cursor or selected resource changed.
|
|
48
|
+
|
|
49
|
+
If the response is ambiguous, retry the **same** reviewed inputs and request ID.
|
|
50
|
+
A matching durable audit returns `already_applied`, including after the preview
|
|
51
|
+
expires. A request ID reused with different intent is rejected. Do not delete the
|
|
52
|
+
cursor or use integration disconnect cleanup to bypass this protocol.
|
|
53
|
+
|
|
54
|
+
After `applied` or `already_applied`, verify normal polling and durable alert
|
|
55
|
+
publication, then run a fresh controlled error and follow issue creation, analysis
|
|
56
|
+
completion, and its rendered customer UI. Recovery alone is not analysis success.
|
|
57
|
+
|
|
58
|
+
## Local transaction verification
|
|
59
|
+
|
|
60
|
+
`scripts/ecs-cursor-recovery.dynamodb.ts` exercises the production recovery module
|
|
61
|
+
against an isolated DynamoDB Local endpoint. It creates and removes one test table
|
|
62
|
+
and rejects non-loopback targets:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
DYNAMODB_LOCAL_ENDPOINT=http://127.0.0.1:58039 \
|
|
66
|
+
node --import tsx scripts/ecs-cursor-recovery.dynamodb.ts
|
|
67
|
+
```
|
|
@@ -57,6 +57,8 @@ Each check is one reminder plus its grounding:
|
|
|
57
57
|
(it never blocks a push). Surface them to the user and let them decide.
|
|
58
58
|
- An **empty result is normal and useful** — it means nothing in memory maps to this change.
|
|
59
59
|
Don't manufacture concerns; say memory had nothing to flag.
|
|
60
|
+
- A result with `degraded: true` means the check **did not run** (its `reason` says why).
|
|
61
|
+
Tell the user preflight could not run; never report it as an all-clear.
|
|
60
62
|
- Higher `seen N×` and `critical`/`high` severity deserve the most attention.
|
|
61
63
|
|
|
62
64
|
## MCP equivalent
|