Skip to content

Commit 8d72a0f

Browse files
committed
개선: Machine 첫 결과 의미 통일
initializer와 doctor가 machine.run 기반 구조화 행동을 네 adapter에 동일하게 제공한다. JavaScript와 Python doctor API가 complete preflight와 blocking report를 보존한다. 검증: npm test, npm run test:package, npm run test:control-product, npm run test:mcp-product npm run test:python-sdk, npm run test:types
1 parent e816a75 commit 8d72a0f

22 files changed

Lines changed: 286 additions & 56 deletions

File tree

README.ko.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ Machine image 계약을 사용한다.
111111
지원 JavaScript client는 `pyproc/control``PyProcControlClient`를 사용하고, 언어 중립 NDJSON 연결은
112112
설치된 `pyproc-control` 명령을 사용한다. 두 경로는 같은 strict manifest, operation catalog, 취소 규칙,
113113
검증된 attachment framing을 사용한다.
114+
Effect-free doctor는 `machine.run`을 정본 의미로 고정하고 shell, JavaScript, Python, MCP에 매핑한 하나의
115+
구조화된 첫 결과 행동도 반환한다.
114116

115117
[Machine Entrance](skills/use-pyproc-machine/references/machine-entrance.md)에서 시작한 뒤
116118
[JavaScript Control SDK](skills/control-pyproc/references/javascript-control.md),

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ contract as root `boot()` and `open()`.
131131
Use `PyProcControlClient` from `pyproc/control` for a supported JavaScript client, or the installed
132132
`pyproc-control` command for a language-neutral NDJSON connection. Both use the same strict manifest,
133133
operation catalog, cancellation rules, and verified attachment framing.
134+
The effect-free doctor also returns one structured first-result action mapped to shell, JavaScript, Python, and
135+
MCP, with `machine.run` as the canonical meaning.
134136

135137
Start with [Machine Entrance](skills/use-pyproc-machine/references/machine-entrance.md), then use the
136138
[JavaScript Control SDK](skills/control-pyproc/references/javascript-control.md), the

mainPlan/9-agentComputerStandardReadiness/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Initiative 9: agent computer와 웹 표준 후보 준비도
22

3-
상태: **진행 중, M0 완료, M1 두 하위 계약 완료**
3+
상태: **진행 중, M0과 M1 완료, M2 다음 작업**
44

55
이 문서는 agent가 pyproc에 들어와 보고, 행동하고, 계산하고, 세션을 넘겨 계속 일하는 전 과정을
66
제품 기준으로 끌어올리는 실행 원장이다. 기존 능력 점수와 증거의 정본은
@@ -14,7 +14,7 @@
1414

1515
| 질문 | 현재 판정 | 실사용 증거 | 완성까지 남은 것 |
1616
|---|---|---|---|
17-
| agent 진입점 | 강함, 완성 아님 | exact install 뒤 `pythonOnly` init, effect-free doctor, CPython 실행이 공개 명령으로 완결되고 현재 main은 package engine을 자동 선택 | client별 다음 명령 의미 통일 |
17+
| agent 진입점 | 매우 강함, 장기 수명주기 검증은 계속 | exact install 뒤 package engine 자동 선택, effect-free doctor, 네 adapter의 같은 CPython 첫 결과가 공개 계약으로 완결 | M2의 반복 작업과 정리 수명주기 |
1818
| 눈과 팔 | 강함, 완성 아님 | APX Situation이 링크를 식별하고 proof-carrying click 뒤 `Smallest start` 출현을 confirmed로 봉인. 현재 main은 첫 문서 교체를 effect 재시도 없이 수렴 | 장기 반복 수명주기와 실제 GPU visual oracle 확대 |
1919
| 비-agent 컴퓨팅 몸체 | 훌륭한 브라우저 컴퓨터, 로컬 OS 완전 대체는 아님 | owned CPython, worker process, OPFS disk, checkpoint, Machine image, Python과 x86 guest gate | 임의 native wheel, shared-memory thread, wasm 도구층, Node guest, quota 축출 계약 |
2020
| 단독 자립성 | Python 기본 Machine은 높음, 전체 WebComputer는 미완성 | source-built CPython과 stdlib가 npm에 포함되고 기본 부팅의 제3자 요청은 0 | x86 emulator와 firmware의 독립 재현, GPU 실기 CI, 브라우저 범위 확대 |
@@ -69,13 +69,15 @@ implementation experience는 독립적이고 상호운용 가능한 구현, 저
6969

7070
### M1. Machine Entrance 첫 결과 수렴
7171

72-
판정: **진행 중**
72+
판정: **완료**
7373

7474
- 완료: exact version 설치 뒤 engine 경로를 사람이 조립하지 않아도 package-owned CPython을 선택한다.
7575
명시한 `--engine-root`만 override로 사용하며 결과와 생성 README에 선택 출처와 절대 경로를 남긴다.
7676
- 완료: COI bootstrap의 첫 문서 교체를 관찰하고 같은 typed focus의 새 capability를 한 번 재발급한다.
7777
`notSent` 외에는 재시도하지 않고, 유일성이나 권한이 달라지거나 두 번째 교체가 생기면 중단한다.
78-
- doctor의 다음 명령이 shell, JavaScript, Python, MCP에서 같은 의미를 가진다.
78+
- 완료: initializer와 doctor가 같은 구조화된 `next.firstResult`를 반환한다. 부모 의미는
79+
`machine.run`과 한 입력으로 고정하고 shell argument vector, JavaScript와 Python SDK method, MCP tool을
80+
adapter로 둔다.
7981

8082
첫 하위 계약 증거:
8183

@@ -85,9 +87,16 @@ implementation experience는 독립적이고 상호운용 가능한 구현, 저
8587
- 첫 문서 교체 probe는 수정 전 stale locator와 effect 0을 재현했고, 수정 후 postcondition confirmed와
8688
effect 정확히 1회로 수렴했다. 설치 MCP 제품 gate도 같은 여정을 고정한다.
8789
- browser control, 3회 48-action stress, APX 11개 실브라우저 게이트가 GREEN이다.
90+
- `PyProcControlClient.doctor()``PyProcClient.doctor()`는 complete doctor와 representable blocking report를
91+
그대로 반환한다. 기존 `check()`는 startup compatibility surface로 유지한다.
92+
- packed install에서 doctor가 준 exact shell arguments, JavaScript method, Python method, MCP tool이 모두
93+
canonical `machine.run`으로 42를 반환했다. Control 23개, MCP 22개, Python wheel과 sdist 5개 제품 gate가
94+
GREEN이다.
8895

8996
### M2. Eyes와 Arms 장기 수명주기
9097

98+
판정: **다음 작업**
99+
91100
- 반복 Situation, screenshot, proof-carrying action, artifact cleanup에서 handle과 process 잔류 0을 증명한다.
92101
- stale, ambiguous, occluded, navigation 교체를 wrong effect 없이 자동 수렴시키는 상한을 고정한다.
93102
- hardware GPU runner에서 pixel 결과 oracle을 추가해 수동 증거 상한을 제거한다.

pythonSdk/src/pyprocControl/client.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,34 @@ def start(cls, configPath: str | os.PathLike[str], *,
179179
def check(cls, configPath: str | os.PathLike[str], *,
180180
command: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None = None,
181181
timeout: float = 30.0) -> dict[str, Any]:
182-
args = [*resolvedCommand(command), "--config", str(Path(configPath).resolve()), "--check"]
183-
completed = subprocess.run(args, text=True, encoding="utf-8", errors="strict",
184-
capture_output=True, timeout=timeout, check=True)
185-
report = json.loads(completed.stdout)
182+
return cls._entranceReport(configPath, command=command, timeout=timeout, mode="check")
183+
184+
@classmethod
185+
def doctor(cls, configPath: str | os.PathLike[str], *,
186+
command: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None = None,
187+
timeout: float = 30.0) -> dict[str, Any]:
188+
return cls._entranceReport(configPath, command=command, timeout=timeout, mode="doctor")
189+
190+
@classmethod
191+
def _entranceReport(cls, configPath: str | os.PathLike[str], *,
192+
command: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None,
193+
timeout: float, mode: str) -> dict[str, Any]:
194+
config = str(Path(configPath).resolve())
195+
entrance = ["doctor", "--config", config] if mode == "doctor" else ["--config", config, "--check"]
196+
completed = subprocess.run([*resolvedCommand(command), *entrance], text=True, encoding="utf-8",
197+
errors="strict", capture_output=True, timeout=timeout, check=False)
198+
try:
199+
report = json.loads(completed.stdout)
200+
except json.JSONDecodeError as error:
201+
completed.check_returncode()
202+
raise RuntimeError(f"pyproc-control {mode} did not return JSON") from error
203+
if mode == "doctor" and isinstance(report, dict) and isinstance(report.get("ok"), bool):
204+
if report["ok"] != (completed.returncode == 0):
205+
raise RuntimeError("pyproc-control doctor exit and report disagree")
206+
return report
207+
completed.check_returncode()
186208
if not isinstance(report, dict) or report.get("ok") is not True:
187-
raise RuntimeError("pyproc-control preflight did not return an ok report")
209+
raise RuntimeError(f"pyproc-control {mode} did not return an ok report")
188210
return report
189211

190212
@property

scripts/controlProtocol/controlApi.d.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,50 @@ export interface MachineRunOutput {
2121
readonly value: string | null;
2222
}
2323

24+
export interface MachineFirstResult {
25+
readonly schemaVersion: 1;
26+
readonly operation: "machine.run";
27+
readonly input: Readonly<{ readonly code: string }>;
28+
readonly shell: Readonly<{ readonly command: "pyproc-control"; readonly arguments: readonly string[] }>;
29+
readonly javascript: Readonly<{
30+
readonly module: "pyproc/control";
31+
readonly client: "PyProcControlClient";
32+
readonly startMethod: "start";
33+
readonly startArguments: readonly string[];
34+
readonly method: "runPython";
35+
readonly arguments: readonly string[];
36+
}>;
37+
readonly python: Readonly<{
38+
readonly module: "pyprocControl";
39+
readonly client: "PyProcClient";
40+
readonly startMethod: "start";
41+
readonly startArguments: readonly string[];
42+
readonly method: "runPython";
43+
readonly arguments: readonly string[];
44+
}>;
45+
readonly mcp: Readonly<{
46+
readonly command: "pyproc-mcp";
47+
readonly serverArguments: readonly string[];
48+
readonly tool: "pythonRun";
49+
readonly arguments: Readonly<{ readonly code: string }>;
50+
}>;
51+
}
52+
53+
export interface MachineDoctorReport extends Readonly<Record<string, unknown>> {
54+
readonly ok: boolean;
55+
readonly configPath: string;
56+
readonly checks: readonly Readonly<Record<string, unknown>>[];
57+
readonly blocking: readonly Readonly<Record<string, unknown>>[];
58+
readonly advisory: readonly Readonly<Record<string, unknown>>[];
59+
readonly next: Readonly<{
60+
readonly doctor: string;
61+
readonly start: string;
62+
readonly run: string;
63+
readonly mcp: string;
64+
readonly firstResult: MachineFirstResult;
65+
}>;
66+
}
67+
2468
export interface MachineImageOutput {
2569
readonly kind: "machineImage";
2670
readonly mimeType: "application/x-pymachine";
@@ -863,6 +907,8 @@ export class PyProcControlClient {
863907
static start(configPath: string, options?: ControlProcessOptions): Promise<PyProcControlClient>;
864908
static check(configPath: string, options?: ControlProcessOptions & { readonly timeoutMs?: number }):
865909
Promise<Readonly<Record<string, unknown>>>;
910+
static doctor(configPath: string, options?: ControlProcessOptions & { readonly timeoutMs?: number }):
911+
Promise<MachineDoctorReport>;
866912
readonly ready: Promise<this>;
867913
readonly operations: readonly string[];
868914
readonly diagnostics: string;

scripts/controlProtocol/controlApi.js

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ async function waitForExit(child, timeoutMs) {
6565
await exited;
6666
}
6767

68-
async function runCheck(configPath, options) {
68+
async function runEntranceReport(configPath, options, mode) {
6969
const command = controlCommand(options.command);
70-
const timeoutMs = positiveTimeout(options.timeoutMs, "control check timeoutMs", DEFAULT_STARTUP_TIMEOUT_MS);
71-
const child = spawn(command[0], [...command.slice(1), "--config", controlConfigPath(configPath), "--check"], {
70+
const timeoutMs = positiveTimeout(options.timeoutMs, `control ${mode} timeoutMs`, DEFAULT_STARTUP_TIMEOUT_MS);
71+
const args = mode === "doctor" ? ["doctor", "--config", controlConfigPath(configPath)]
72+
: ["--config", controlConfigPath(configPath), "--check"];
73+
const child = spawn(command[0], [...command.slice(1), ...args], {
7274
cwd: options.cwd || process.cwd(), env: options.env || process.env, windowsHide: true,
7375
stdio: ["ignore", "pipe", "pipe"],
7476
});
@@ -86,19 +88,36 @@ async function runCheck(configPath, options) {
8688
if (outcome.state === "timeout") {
8789
child.kill("SIGTERM");
8890
await waitForExit(child, DEFAULT_SHUTDOWN_TIMEOUT_MS);
89-
throw timeoutError("pyproc-control preflight timed out");
91+
throw timeoutError(`pyproc-control ${mode} timed out`);
9092
}
9193
const { code, signal } = unwrap(outcome);
92-
if (code !== 0) throw new Error(`pyproc-control preflight failed (${code ?? signal})\n${stderr}`);
93-
let report;
94+
let report = null;
9495
try { report = JSON.parse(stdout); }
95-
catch (error) { throw new Error("pyproc-control preflight did not return JSON", { cause: error }); }
96+
catch (error) {
97+
if (code !== 0) throw new Error(`pyproc-control ${mode} failed (${code ?? signal})\n${stderr}`);
98+
throw new Error(`pyproc-control ${mode} did not return JSON`, { cause: error });
99+
}
100+
if (mode === "doctor" && report && typeof report === "object" && typeof report.ok === "boolean") {
101+
if (report.ok !== (code === 0)) {
102+
throw new Error(`pyproc-control doctor exit and report disagree (${code ?? signal})`);
103+
}
104+
return Object.freeze(report);
105+
}
106+
if (code !== 0) throw new Error(`pyproc-control ${mode} failed (${code ?? signal})\n${stderr}`);
96107
if (!report || typeof report !== "object" || report.ok !== true) {
97-
throw new Error("pyproc-control preflight did not return an ok report");
108+
throw new Error(`pyproc-control ${mode} did not return an ok report`);
98109
}
99110
return Object.freeze(report);
100111
}
101112

113+
function runCheck(configPath, options) {
114+
return runEntranceReport(configPath, options, "check");
115+
}
116+
117+
function runDoctor(configPath, options) {
118+
return runEntranceReport(configPath, options, "doctor");
119+
}
120+
102121
export { ControlRemoteError } from "./controlClient.js";
103122
export { ExecutionMemoryRegistry, createExecutionMemoryRegistry }
104123
from "../executionMemory/executionMemoryRegistry.js";
@@ -439,6 +458,10 @@ export class PyProcControlClient extends ControlStdioClient {
439458
return runCheck(configPath, options);
440459
}
441460

461+
static doctor(configPath, options = {}) {
462+
return runDoctor(configPath, options);
463+
}
464+
442465
requestAsync(operation, input = {}, { requestId, spaceId } = {}) {
443466
const id = requestId || `request:${++this._sequence}`;
444467
const result = super.request(operation, input, { requestId: id, spaceId });

scripts/machineEntrance/machineDoctor.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CONTROL_PYTHON_TOOLS } from "../controlProtocol/controlProduct.mjs";
1010
import { controlOperationCatalog } from "../controlProtocol/controlOperations.js";
1111
import { loadMcpProductConfig } from "../mcpProductConfig.mjs";
1212
import { inspectEngineDistribution } from "./engineInspection.js";
13+
import { createMachineNext } from "./machineNext.js";
1314
import { verifyWindowsNativeInstallation } from "../actuation/windowsNativeHost.js";
1415

1516
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -18,14 +19,6 @@ function check(code, severity, explanation, details = {}) {
1819
return Object.freeze({ code, severity, explanation, ...details });
1920
}
2021

21-
function safeNext(configPath) {
22-
return Object.freeze({
23-
doctor: `pyproc-control doctor --config "${configPath}"`,
24-
start: `pyproc-control --config "${configPath}"`,
25-
run: `pyproc-control run --config "${configPath}" --code "40 + 2"`,
26-
});
27-
}
28-
2922
async function installedVersion(packageRoot) {
3023
const value = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
3124
if (typeof value.version !== "string") throw new Error("installed package version is unavailable");
@@ -49,6 +42,11 @@ async function automationFacts(loaded) {
4942
enabled: true,
5043
provider,
5144
cdpEndpoint: provider === "nativeCdp",
45+
allowedOrigins: loaded.browserControl.targetOrigins,
46+
actions: loaded.browserControl.actions,
47+
rawMethods: loaded.browserControl.rawMethods,
48+
maxRisk: loaded.browserControl.maxRisk,
49+
artifacts: loaded.browserControl.artifacts,
5250
operations: controlOperationCatalog(tools).map((entry) => entry.name),
5351
...(replay ? { replay: Object.freeze({ recordingId: replay.recordingId, entries: replay.entries.length,
5452
finalSha256: replay.finalSha256, sourceProvider: replay.provider.providerKind }) } : {}),
@@ -72,7 +70,7 @@ export async function inspectMachineProfile(configPath, {
7270
}));
7371
return Object.freeze({ ok: false, configPath: resolvedConfig, checks: Object.freeze(checks),
7472
blocking: Object.freeze(checks.filter((entry) => entry.severity === "blocking")),
75-
advisory: Object.freeze([]), next: safeNext(resolvedConfig) });
73+
advisory: Object.freeze([]), next: createMachineNext(resolvedConfig) });
7674
}
7775

7876
try {
@@ -152,6 +150,6 @@ export async function inspectMachineProfile(configPath, {
152150
advisory: Object.freeze(advisory),
153151
automation,
154152
windowsNative,
155-
next: safeNext(loaded.configPath),
153+
next: createMachineNext(loaded.configPath),
156154
});
157155
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// machineNext.js - 한 Machine 첫 결과를 모든 공개 adapter의 같은 operation으로 표현한다.
2+
import { resolve } from "node:path";
3+
import { controlOperationForTool } from "../controlProtocol/controlOperations.js";
4+
5+
const FIRST_RESULT_CODE = "40 + 2";
6+
const MCP_RUN_TOOL = "pythonRun";
7+
8+
function frozenArray(values) {
9+
return Object.freeze([...values]);
10+
}
11+
12+
export function createMachineNext(configPath) {
13+
if (typeof configPath !== "string" || !configPath) {
14+
throw new TypeError("Machine next configPath must be a non-empty string");
15+
}
16+
const resolvedConfig = resolve(configPath);
17+
const input = Object.freeze({ code: FIRST_RESULT_CODE });
18+
const operation = controlOperationForTool(MCP_RUN_TOOL);
19+
if (!operation) throw new Error(`Control operation is missing for ${MCP_RUN_TOOL}`);
20+
21+
const firstResult = Object.freeze({
22+
schemaVersion: 1,
23+
operation,
24+
input,
25+
shell: Object.freeze({
26+
command: "pyproc-control",
27+
arguments: frozenArray(["run", "--config", resolvedConfig, "--code", FIRST_RESULT_CODE]),
28+
}),
29+
javascript: Object.freeze({
30+
module: "pyproc/control",
31+
client: "PyProcControlClient",
32+
startMethod: "start",
33+
startArguments: frozenArray([resolvedConfig]),
34+
method: "runPython",
35+
arguments: frozenArray([FIRST_RESULT_CODE]),
36+
}),
37+
python: Object.freeze({
38+
module: "pyprocControl",
39+
client: "PyProcClient",
40+
startMethod: "start",
41+
startArguments: frozenArray([resolvedConfig]),
42+
method: "runPython",
43+
arguments: frozenArray([FIRST_RESULT_CODE]),
44+
}),
45+
mcp: Object.freeze({
46+
command: "pyproc-mcp",
47+
serverArguments: frozenArray(["--config", resolvedConfig]),
48+
tool: MCP_RUN_TOOL,
49+
arguments: input,
50+
}),
51+
});
52+
53+
return Object.freeze({
54+
doctor: `pyproc-control doctor --config "${resolvedConfig}"`,
55+
start: `pyproc-control --config "${resolvedConfig}"`,
56+
run: `pyproc-control run --config "${resolvedConfig}" --code "${FIRST_RESULT_CODE}"`,
57+
mcp: `pyproc-mcp --config "${resolvedConfig}"`,
58+
firstResult,
59+
});
60+
}

scripts/machineEntrance/profileInitializer.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from
44
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { compileMachineProfile } from "./machineProfile.js";
7+
import { createMachineNext } from "./machineNext.js";
78

89
export const MACHINE_PROFILE_DIRECTORY = ".pyproc";
910
export const MACHINE_PROFILE_FILES = Object.freeze(["manifest.json", "client.json", "README.md"]);
@@ -184,10 +185,6 @@ export async function initializeMachineProfile({
184185
readmePath: join(paths.output, "README.md"),
185186
dryRun,
186187
overwritten: overwrite && present.length > 0,
187-
next: Object.freeze({
188-
doctor: `pyproc-control doctor --config "${join(paths.output, "manifest.json")}"`,
189-
run: `pyproc-control run --config "${join(paths.output, "manifest.json")}" --code "40 + 2"`,
190-
mcp: `pyproc-mcp --config "${join(paths.output, "manifest.json")}"`,
191-
}),
188+
next: createMachineNext(join(paths.output, "manifest.json")),
192189
});
193190
}

0 commit comments

Comments
 (0)