@awak-app/simy-cli 0.5.43 → 0.5.45
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/package.json +3 -3
- package/src/agent.js +64 -0
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +14 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +4 -0
- package/src/codex-plugins/simy/hooks/dev-work.mjs +270 -0
- package/src/codex-plugins/simy/hooks/hooks.json +9 -0
- package/src/desktop-host.js +26 -3
- package/src/dev-work-events.js +103 -0
- package/src/session-supervision/action-association-host.js +37 -4
- package/src/session-supervision/action-deadline-store.js +57 -0
- package/src/session-supervision/action-deadline.js +310 -0
- package/src/session-supervision/association-store.js +369 -0
- package/src/session-supervision/index.js +2 -1
- package/src/sqm/claude-plugin.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.45",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
|
|
38
38
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
39
39
|
"check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
|
|
40
|
-
"check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
|
|
40
|
+
"check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
|
|
41
41
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
42
42
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
43
43
|
"check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
44
44
|
"check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
|
|
45
|
-
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
45
|
+
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
46
46
|
"check:package": "node ./scripts/check-package-contents.js",
|
|
47
47
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
48
48
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -35,6 +35,12 @@ import {
|
|
|
35
35
|
writeSession,
|
|
36
36
|
} from "./session-store.js";
|
|
37
37
|
import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
|
|
38
|
+
import {
|
|
39
|
+
DEV_WORK_EVENT_MAX_BYTES,
|
|
40
|
+
DevWorkEventError,
|
|
41
|
+
normalizeDevWorkEvent,
|
|
42
|
+
sendDevWorkEvent,
|
|
43
|
+
} from "./dev-work-events.js";
|
|
38
44
|
import {
|
|
39
45
|
cleanupExpiredRuns,
|
|
40
46
|
cleanupRunAttachments,
|
|
@@ -797,6 +803,53 @@ export async function startAgent({
|
|
|
797
803
|
json(res, 200, refreshAvailableCapabilities());
|
|
798
804
|
return;
|
|
799
805
|
}
|
|
806
|
+
if (url.pathname === "/v1/dev-work-events") {
|
|
807
|
+
// Local-only: the SIMY plugin hook reports Git state from this machine.
|
|
808
|
+
// A browser (including SIMY Web) always sends Origin, so any Origin is
|
|
809
|
+
// refused; the hook posts without one.
|
|
810
|
+
if (req.headers.origin !== undefined) {
|
|
811
|
+
json(res, 403, { error: "dev-work events are accepted only from local hooks" });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (req.method !== "POST") {
|
|
815
|
+
json(res, 405, { error: "method not allowed" });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) {
|
|
819
|
+
json(res, 415, { error: "JSON body required" });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
let event;
|
|
823
|
+
try {
|
|
824
|
+
event = normalizeDevWorkEvent(await readBoundedJson(req, DEV_WORK_EVENT_MAX_BYTES));
|
|
825
|
+
} catch (error) {
|
|
826
|
+
json(res, error instanceof DevWorkEventError ? error.status : 400, {
|
|
827
|
+
error: "Invalid dev-work event.",
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
|
|
832
|
+
json(res, 401, { error: "Sign in to SIMY CLI to report dev-work." });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
json(
|
|
837
|
+
res,
|
|
838
|
+
200,
|
|
839
|
+
await sendDevWorkEvent({
|
|
840
|
+
event,
|
|
841
|
+
session,
|
|
842
|
+
apiOrigin,
|
|
843
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
json(res, error instanceof DevWorkEventError ? error.status : 502, {
|
|
848
|
+
error: error instanceof DevWorkEventError ? error.message : "dev-work event failed",
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
800
853
|
if (url.pathname === "/v1/integrations/sqm-agent") {
|
|
801
854
|
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
|
|
802
855
|
json(res, 401, { error: "Reconnect SIMY CLI before managing the SQM agent skill." });
|
|
@@ -2738,6 +2791,17 @@ async function readJson(req) {
|
|
|
2738
2791
|
return raw ? JSON.parse(raw) : {};
|
|
2739
2792
|
}
|
|
2740
2793
|
|
|
2794
|
+
async function readBoundedJson(req, maxBytes) {
|
|
2795
|
+
const chunks = [];
|
|
2796
|
+
let size = 0;
|
|
2797
|
+
for await (const chunk of req) {
|
|
2798
|
+
size += chunk.length;
|
|
2799
|
+
if (size > maxBytes) throw new Error("body too large");
|
|
2800
|
+
chunks.push(chunk);
|
|
2801
|
+
}
|
|
2802
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2741
2805
|
async function readCodingLoopStart(req) {
|
|
2742
2806
|
const contentType = String(req.headers["content-type"] || "");
|
|
2743
2807
|
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.45";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -30,3 +30,17 @@ Private state is under `SIMY_HOME/delivery-hooks` (default `~/.simy/delivery-hoo
|
|
|
30
30
|
keyed by provider and session ID and bound to the canonical working directory.
|
|
31
31
|
Historical hook observations do not prove hooks are still enabled. See the
|
|
32
32
|
bundled delivery-loop skill for checkpoint commands and explicit recovery.
|
|
33
|
+
|
|
34
|
+
## Project 実装状況 (dev-work) hook
|
|
35
|
+
|
|
36
|
+
`dev-work.mjs` runs on Stop in both distributions. In a Git work tree with a
|
|
37
|
+
named branch, a commit and an `origin` remote it reports `owner/name`, branch,
|
|
38
|
+
HEAD, the open PR URL (via `gh pr view`, only when `gh` is installed) and the
|
|
39
|
+
Project from `git config simy.project-id` to the local SIMY CLI agent over
|
|
40
|
+
loopback (`POST /v1/dev-work-events`). The agent forwards it to SIMY Web as the
|
|
41
|
+
signed-in device; the hook never reads or holds a SIMY credential. The provider
|
|
42
|
+
session ID is sent only as a SHA-256 digest. An unchanged report for the same
|
|
43
|
+
session is skipped locally (state under `PLUGIN_DATA/dev-work`). The hook never
|
|
44
|
+
blocks: it has short timeouts, always exits 0 and writes only short error lines
|
|
45
|
+
to stderr. Link a repository to a Project with
|
|
46
|
+
`git config simy.project-id <project-uuid>`.
|
|
@@ -357,6 +357,10 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
357
357
|
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
358
358
|
const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
|
|
359
359
|
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
360
|
+
if (managedState && previous.associationTaskId) {
|
|
361
|
+
managedState.taskId = previous.associationTaskId;
|
|
362
|
+
if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
|
|
363
|
+
}
|
|
360
364
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
361
365
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
362
366
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// P-05: report this coding session's Git state (repo, branch, HEAD, optional
|
|
4
|
+
// PR and Project) to the local SIMY CLI agent, which forwards it to SIMY as
|
|
5
|
+
// the signed-in device. Shipped with both the Codex and Claude Code plugins.
|
|
6
|
+
//
|
|
7
|
+
// The hook holds no SIMY credential and never blocks the coding session: every
|
|
8
|
+
// step has a short timeout, failures are silent (a short stderr line at most,
|
|
9
|
+
// never a secret), and it always exits 0. The raw provider session id is never
|
|
10
|
+
// sent or stored; only a provider-scoped SHA-256 of it leaves this process.
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
20
|
+
const MAX_DISCOVERY_RECORDS = 16;
|
|
21
|
+
const GIT_TIMEOUT_MS = 1_500;
|
|
22
|
+
const GH_TIMEOUT_MS = 3_000;
|
|
23
|
+
const POST_TIMEOUT_MS = 6_000;
|
|
24
|
+
const HARD_DEADLINE_MS = 9_000;
|
|
25
|
+
const SOURCES = { codex: "codex", claude: "claude_code" };
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
const HEAD_SHA = /^[0-9a-f]{40}$/;
|
|
28
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
29
|
+
const PR_URL =
|
|
30
|
+
/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/pull\/[0-9]{1,10}$/;
|
|
31
|
+
|
|
32
|
+
function run(command, args, { cwd, timeout, env }) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
try {
|
|
35
|
+
execFile(
|
|
36
|
+
command,
|
|
37
|
+
args,
|
|
38
|
+
{ cwd, timeout, env, windowsHide: true, maxBuffer: 64 * 1024 },
|
|
39
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim()),
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
resolve(null);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** owner/name from an origin remote URL (https, ssh or scp-like), or null. */
|
|
48
|
+
export function repoFromRemote(remote) {
|
|
49
|
+
const value = String(remote || "").trim();
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
let path;
|
|
52
|
+
const scp = value.match(/^[^@/\s]+@[^:/\s]+:(.+)$/);
|
|
53
|
+
if (scp) {
|
|
54
|
+
path = scp[1];
|
|
55
|
+
} else {
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(value);
|
|
58
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
|
|
59
|
+
path = url.pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parts = path.replace(/\.git$/i, "").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
65
|
+
if (parts.length < 2) return null;
|
|
66
|
+
const repo = `${parts.at(-2)}/${parts.at(-1)}`;
|
|
67
|
+
return REPO.test(repo) ? repo : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collects the report for `cwd`, or null when it is not a Git work tree with a
|
|
72
|
+
* named branch, a commit, and an origin remote.
|
|
73
|
+
*/
|
|
74
|
+
export async function collectDevWorkEvent({
|
|
75
|
+
cwd,
|
|
76
|
+
provider,
|
|
77
|
+
sessionId,
|
|
78
|
+
env = process.env,
|
|
79
|
+
runImpl = run,
|
|
80
|
+
}) {
|
|
81
|
+
const source = SOURCES[provider];
|
|
82
|
+
if (!source || typeof sessionId !== "string" || !sessionId.trim() || !cwd) return null;
|
|
83
|
+
const git = (args) => runImpl("git", args, { cwd, timeout: GIT_TIMEOUT_MS, env });
|
|
84
|
+
if ((await git(["rev-parse", "--is-inside-work-tree"])) !== "true") return null;
|
|
85
|
+
const [headSha, branch, remote, projectId] = await Promise.all([
|
|
86
|
+
git(["rev-parse", "--verify", "-q", "HEAD"]),
|
|
87
|
+
git(["symbolic-ref", "--short", "-q", "HEAD"]),
|
|
88
|
+
git(["remote", "get-url", "origin"]),
|
|
89
|
+
git(["config", "--get", "simy.project-id"]),
|
|
90
|
+
]);
|
|
91
|
+
const repo = repoFromRemote(remote);
|
|
92
|
+
if (!headSha || !HEAD_SHA.test(headSha) || !branch || !repo) return null;
|
|
93
|
+
// Only repositories the owner linked to a Project (`git config simy.project-id`)
|
|
94
|
+
// are reported; other repositories' names and branches never leave the machine.
|
|
95
|
+
if (!projectId || !UUID.test(projectId)) return null;
|
|
96
|
+
return {
|
|
97
|
+
repo,
|
|
98
|
+
branch,
|
|
99
|
+
head_sha: headSha,
|
|
100
|
+
session_id: sha256(`${provider}:${sessionId.trim()}`),
|
|
101
|
+
source,
|
|
102
|
+
project_id: projectId.toLowerCase(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Best effort: the open PR for the current branch via `gh`, if installed. */
|
|
107
|
+
export async function pullRequestUrl({ cwd, repo, env = process.env, runImpl = run }) {
|
|
108
|
+
const url = await runImpl("gh", ["pr", "view", "--json", "url", "--jq", ".url"], {
|
|
109
|
+
cwd,
|
|
110
|
+
timeout: GH_TIMEOUT_MS,
|
|
111
|
+
env: { ...env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
|
|
112
|
+
});
|
|
113
|
+
if (!url || !PR_URL.test(url)) return null;
|
|
114
|
+
return url.toLowerCase().startsWith(`https://github.com/${repo.toLowerCase()}/pull/`) ? url : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function findAgentPort({ env = process.env, sessionRoot }) {
|
|
118
|
+
const explicit = normalizeOrigin(env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN);
|
|
119
|
+
if (env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN) {
|
|
120
|
+
return explicit ? readPort(sessionRoot, explicit) : null;
|
|
121
|
+
}
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = (await readdir(join(sessionRoot, "connections")))
|
|
125
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
126
|
+
.sort()
|
|
127
|
+
.slice(0, MAX_DISCOVERY_RECORDS);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
try {
|
|
133
|
+
const record = JSON.parse(await readFile(join(sessionRoot, "connections", entry), "utf8"));
|
|
134
|
+
const origin = normalizeOrigin(record?.web_origin);
|
|
135
|
+
if (!origin || entry !== `${sha256(origin)}.json`) continue;
|
|
136
|
+
const port = await readPort(sessionRoot, origin);
|
|
137
|
+
if (port) return port;
|
|
138
|
+
} catch {
|
|
139
|
+
// Unreadable discovery records are skipped.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readPort(sessionRoot, origin) {
|
|
146
|
+
try {
|
|
147
|
+
const record = JSON.parse(
|
|
148
|
+
await readFile(join(sessionRoot, "connections", `${sha256(origin)}.json`), "utf8"),
|
|
149
|
+
);
|
|
150
|
+
return record?.schema_version === 1 &&
|
|
151
|
+
record?.web_origin === origin &&
|
|
152
|
+
Number.isInteger(record?.port) &&
|
|
153
|
+
record.port >= 1 &&
|
|
154
|
+
record.port <= 65_535
|
|
155
|
+
? record.port
|
|
156
|
+
: null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeOrigin(raw) {
|
|
163
|
+
try {
|
|
164
|
+
const url = new URL(raw);
|
|
165
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null;
|
|
166
|
+
if ((url.pathname && url.pathname !== "/") || url.search || url.hash) return null;
|
|
167
|
+
return url.origin;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runDevWorkHook({
|
|
174
|
+
provider,
|
|
175
|
+
input = {},
|
|
176
|
+
env = process.env,
|
|
177
|
+
fetchImpl = globalThis.fetch,
|
|
178
|
+
runImpl = run,
|
|
179
|
+
}) {
|
|
180
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
181
|
+
const sessionId = input.session_id || input.sessionId || env.CODEX_THREAD_ID;
|
|
182
|
+
const event = await collectDevWorkEvent({ cwd, provider, sessionId, env, runImpl });
|
|
183
|
+
if (!event) return { sent: false, reason: "not_applicable" };
|
|
184
|
+
|
|
185
|
+
const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
186
|
+
const pluginData =
|
|
187
|
+
env.PLUGIN_DATA?.trim() || env.CLAUDE_PLUGIN_DATA?.trim() || join(sessionRoot, "plugin-data", "simy");
|
|
188
|
+
// The upstream observation is keyed by (Project, repo, HEAD, session): a
|
|
189
|
+
// resend of the same state changes nothing, so skip it (and `gh`) locally.
|
|
190
|
+
const fingerprint = sha256(JSON.stringify([event.session_id, event.repo, event.head_sha, event.project_id ?? null]));
|
|
191
|
+
const statePath = join(pluginData, "dev-work", `${event.session_id}.json`);
|
|
192
|
+
if ((await readState(statePath))?.fingerprint === fingerprint) {
|
|
193
|
+
return { sent: false, reason: "unchanged" };
|
|
194
|
+
}
|
|
195
|
+
const port = await findAgentPort({ env, sessionRoot });
|
|
196
|
+
if (!port) return { sent: false, reason: "agent_unavailable" };
|
|
197
|
+
const prUrl = await pullRequestUrl({ cwd, repo: event.repo, env, runImpl });
|
|
198
|
+
if (prUrl) event.pr_url = prUrl;
|
|
199
|
+
|
|
200
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/dev-work-events`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify(event),
|
|
204
|
+
redirect: "error",
|
|
205
|
+
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
|
206
|
+
});
|
|
207
|
+
await response.body?.cancel?.().catch(() => {});
|
|
208
|
+
if (!response.ok) return { sent: false, reason: `http_${response.status}` };
|
|
209
|
+
await writeState(statePath, { fingerprint }).catch(() => {});
|
|
210
|
+
return { sent: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readState(target) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(await readFile(target, "utf8"));
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function writeState(target, state) {
|
|
222
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
223
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
224
|
+
await writeFile(temporary, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
225
|
+
await rename(temporary, target);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sha256(value) {
|
|
229
|
+
return createHash("sha256").update(value).digest("hex");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readHookInput(stream = process.stdin) {
|
|
233
|
+
let raw = "";
|
|
234
|
+
for await (const chunk of stream) {
|
|
235
|
+
raw += String(chunk);
|
|
236
|
+
if (Buffer.byteLength(raw) > MAX_INPUT_BYTES) return {};
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(raw || "{}");
|
|
240
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
241
|
+
} catch {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function main(provider = "codex") {
|
|
247
|
+
// Never outlive the provider's hook budget, whatever a child process does.
|
|
248
|
+
const deadline = setTimeout(() => process.exit(0), HARD_DEADLINE_MS);
|
|
249
|
+
deadline.unref?.();
|
|
250
|
+
try {
|
|
251
|
+
const result = await runDevWorkHook({ provider, input: await readHookInput() });
|
|
252
|
+
// Signed out (401) and a disabled Web gateway (404) are expected states.
|
|
253
|
+
if (
|
|
254
|
+
!result.sent &&
|
|
255
|
+
result.reason.startsWith("http_") &&
|
|
256
|
+
!["http_401", "http_404"].includes(result.reason)
|
|
257
|
+
) {
|
|
258
|
+
process.stderr.write(`SIMY dev-work report was not recorded (${result.reason}).\n`);
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
process.stderr.write("SIMY dev-work report failed.\n");
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(deadline);
|
|
264
|
+
}
|
|
265
|
+
process.exitCode = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
269
|
+
await main(process.argv[2] === "claude" ? "claude" : "codex");
|
|
270
|
+
}
|
|
@@ -50,6 +50,15 @@
|
|
|
50
50
|
"timeout": 3
|
|
51
51
|
}
|
|
52
52
|
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"hooks": [
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'dev-work.mjs')).href); await hook.main('codex');\"",
|
|
59
|
+
"timeout": 10
|
|
60
|
+
}
|
|
61
|
+
]
|
|
53
62
|
}
|
|
54
63
|
],
|
|
55
64
|
"SessionEnd": [
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
1
2
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
2
3
|
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
3
4
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
@@ -48,8 +49,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
48
49
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
49
50
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
50
51
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
51
|
-
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
|
|
52
|
+
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
52
53
|
let actionAssociations = makeActionAssociations();
|
|
54
|
+
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
53
55
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
54
56
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
55
57
|
let shuttingDown = null;
|
|
@@ -105,7 +107,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
105
107
|
await actionAssociations.decorate(status.deliveryHooks);
|
|
106
108
|
const discovery = refreshSessions();
|
|
107
109
|
automaticContinuation?.tick(discovery.sessions);
|
|
108
|
-
|
|
110
|
+
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
111
|
+
await actionAssociations.decorateSessions?.(sessions);
|
|
112
|
+
for (const session of sessions) {
|
|
113
|
+
const context = await actionAssociations.deadlineContext?.(session);
|
|
114
|
+
if (context) session.actionDeadline = await deadlines.reconcile(context);
|
|
115
|
+
else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
116
|
+
const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
|
|
117
|
+
if (run) run.actionDeadline = session.actionDeadline;
|
|
118
|
+
}
|
|
119
|
+
status.sessionSupervision = { ...discovery, sessions };
|
|
109
120
|
if (!closed && !relay) emit({ type: "status", status });
|
|
110
121
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
111
122
|
return publishing;
|
|
@@ -301,7 +312,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
301
312
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
302
313
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
303
314
|
} else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
|
|
304
|
-
await
|
|
315
|
+
const candidate = await selectedSession(command);
|
|
316
|
+
await actionAssociations.correct(command, analysisManager.project(candidate));
|
|
305
317
|
} else if (action === "session_set_auto_continue") {
|
|
306
318
|
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
|
|
307
319
|
if (command.enabled) {
|
|
@@ -316,6 +328,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
316
328
|
const fresh = await selectedSession(command);
|
|
317
329
|
if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
|
|
318
330
|
}
|
|
331
|
+
} else if (action === "session_deadline_decide") {
|
|
332
|
+
if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
|
|
333
|
+
const candidate = await selectedSession(command);
|
|
334
|
+
await actionAssociations.correct({ ...command, action: 'session_action_refresh' }, analysisManager.project(candidate));
|
|
335
|
+
const projected = analysisManager.project(candidate);
|
|
336
|
+
await actionAssociations.decorateSessions?.([projected]);
|
|
337
|
+
const context = await actionAssociations.deadlineContext?.(projected);
|
|
338
|
+
if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
|
|
339
|
+
await deadlines.reconcile(context);
|
|
340
|
+
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
341
|
+
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
319
342
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
320
343
|
const candidate = await selectedSession(command);
|
|
321
344
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P-05: forwards a local coding session's Git state (reported by the SIMY
|
|
5
|
+
* plugin hook) to SIMY Web as the signed-in device, so the Project 実装状況
|
|
6
|
+
* tab can show it. The hook never holds a SIMY credential; this agent adds the
|
|
7
|
+
* device bearer. The Web gateway derives the owner scope from that bearer.
|
|
8
|
+
*/
|
|
9
|
+
// Trailing slash: SIMY Web redirects slashless API paths (308), and this
|
|
10
|
+
// sender refuses redirects so the device token is only sent to this exact URL.
|
|
11
|
+
export const PROJECT_DEV_WORK_EVENTS_PATH = "project-dev-work-events/";
|
|
12
|
+
export const DEV_WORK_EVENT_MAX_BYTES = 4096;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
const REQUIRED_KEYS = ["repo", "branch", "head_sha", "session_id", "source"];
|
|
16
|
+
const OPTIONAL_KEYS = ["pr_url", "project_id"];
|
|
17
|
+
const ALLOWED_KEYS = new Set([...REQUIRED_KEYS, ...OPTIONAL_KEYS]);
|
|
18
|
+
const SOURCES = new Set(["claude_code", "codex", "other"]);
|
|
19
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
20
|
+
const HEAD_SHA = /^[0-9a-f]{7,40}$/;
|
|
21
|
+
// Same conservative `git check-ref-format` subset as the Web gateway.
|
|
22
|
+
const BRANCH = /^(?!-)(?!\/)(?!.*\.\.)(?!.*@\{)(?!.*\/$)(?!.*\.lock$)[^\x00-\x20\x7f~^:?*[\\]{1,255}$/;
|
|
23
|
+
const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
24
|
+
const PR_URL =
|
|
25
|
+
/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/pull\/[0-9]{1,10}$/;
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
|
|
28
|
+
export class DevWorkEventError extends Error {
|
|
29
|
+
constructor(message, status) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns the exact report to forward, or throws a 400 DevWorkEventError. */
|
|
36
|
+
export function normalizeDevWorkEvent(value) {
|
|
37
|
+
const invalid = () => new DevWorkEventError("Invalid dev-work event.", 400);
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
|
|
39
|
+
if (!Object.keys(value).every((key) => ALLOWED_KEYS.has(key))) throw invalid();
|
|
40
|
+
if (!REQUIRED_KEYS.every((key) => typeof value[key] === "string")) throw invalid();
|
|
41
|
+
const event = {};
|
|
42
|
+
for (const key of REQUIRED_KEYS) event[key] = value[key];
|
|
43
|
+
for (const key of OPTIONAL_KEYS) {
|
|
44
|
+
if (value[key] === undefined || value[key] === null) continue;
|
|
45
|
+
if (typeof value[key] !== "string") throw invalid();
|
|
46
|
+
event[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
!REPO.test(event.repo) ||
|
|
50
|
+
!BRANCH.test(event.branch) ||
|
|
51
|
+
!HEAD_SHA.test(event.head_sha) ||
|
|
52
|
+
!SESSION_ID.test(event.session_id) ||
|
|
53
|
+
!SOURCES.has(event.source) ||
|
|
54
|
+
(event.pr_url !== undefined && !PR_URL.test(event.pr_url)) ||
|
|
55
|
+
(event.project_id !== undefined && !UUID.test(event.project_id))
|
|
56
|
+
) {
|
|
57
|
+
throw invalid();
|
|
58
|
+
}
|
|
59
|
+
if (event.project_id !== undefined) event.project_id = event.project_id.toLowerCase();
|
|
60
|
+
return event;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Posts one normalized report to Web. Resolves with the minimal receipt; a
|
|
65
|
+
* disabled gateway (404) or any refusal rejects with a status the local
|
|
66
|
+
* endpoint relays, without Web's message body.
|
|
67
|
+
*/
|
|
68
|
+
export async function sendDevWorkEvent({ event, session, apiOrigin, fetchImpl = globalThis.fetch }) {
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetchImpl(
|
|
72
|
+
webApiUrl(PROJECT_DEV_WORK_EVENTS_PATH, {
|
|
73
|
+
webOrigin: apiOrigin,
|
|
74
|
+
apiBaseUrl: session.api_base_url,
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: webApiHeaders({ token: session.token }, { "Content-Type": "application/json" }),
|
|
79
|
+
body: JSON.stringify(event),
|
|
80
|
+
redirect: "error",
|
|
81
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new DevWorkEventError("SIMY Web could not be reached.", 502);
|
|
86
|
+
}
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
await response.body?.cancel?.().catch(() => {});
|
|
89
|
+
const status = [401, 403, 404, 409, 422, 503].includes(response.status) ? response.status : 502;
|
|
90
|
+
throw new DevWorkEventError("SIMY Web did not record the dev-work event.", status);
|
|
91
|
+
}
|
|
92
|
+
const receipt = await response.json().catch(() => null);
|
|
93
|
+
if (
|
|
94
|
+
!receipt ||
|
|
95
|
+
typeof receipt.job_id !== "string" ||
|
|
96
|
+
!UUID.test(receipt.job_id) ||
|
|
97
|
+
typeof receipt.created !== "boolean" ||
|
|
98
|
+
receipt.project_id !== (event.project_id ?? null)
|
|
99
|
+
) {
|
|
100
|
+
throw new DevWorkEventError("SIMY Web returned an invalid dev-work receipt.", 502);
|
|
101
|
+
}
|
|
102
|
+
return { job_id: receipt.job_id, created: receipt.created, project_id: receipt.project_id };
|
|
103
|
+
}
|