trackly-cli 0.13.3 → 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/README.md +9 -3
- package/contracts/trackly-apply-contract-history.json +12 -2
- package/contracts/trackly-apply-tools.json +16 -5
- package/docs/plans/2026-08-05-001-trackly-apply-durable-recovery-reconciliation-plan.md +475 -0
- package/docs/solutions/integration-issues/openai-mcp-discovery-probe-oauth-scope-failure.md +59 -0
- package/docs/trackly-tools.md +11 -0
- package/lib/agent.js +2 -2
- package/lib/apply-tab-set.js +124 -0
- package/lib/apply-upload.js +66 -0
- package/mcp/apply-tools.js +301 -8
- package/npm-shrinkwrap.json +69 -2
- package/package.json +5 -1
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +12 -4
- package/skills/trackly-apply/references/batch-orchestration.md +34 -0
- package/skills/trackly-apply/references/browser-lifecycle.md +14 -0
- package/skills/trackly-apply/references/browser-upload.md +40 -8
- package/skills/trackly-apply/references/review-handoff.md +87 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const APPLY_TAB_SET_FAILURE_CODES = Object.freeze({
|
|
4
|
+
CONTROLLER_INVENTORY_INCOMPLETE: 'controller_inventory_incomplete',
|
|
5
|
+
USER_INVENTORY_INCOMPLETE: 'user_inventory_incomplete',
|
|
6
|
+
EXPECTED_SET_EMPTY: 'expected_set_empty',
|
|
7
|
+
KEEP_SET_EMPTY_WITH_EXPECTED_TABS: 'keep_set_empty_with_expected_tabs',
|
|
8
|
+
DUPLICATE_EXPECTED_TAB_ID: 'duplicate_expected_tab_id',
|
|
9
|
+
DUPLICATE_KEEP_TAB_ID: 'duplicate_keep_tab_id',
|
|
10
|
+
DUPLICATE_CONTROLLER_TAB_ID: 'duplicate_controller_tab_id',
|
|
11
|
+
DUPLICATE_USER_TAB_ID: 'duplicate_user_tab_id',
|
|
12
|
+
AMBIGUOUS_CROSS_TYPE_TAB_ID: 'ambiguous_cross_type_tab_id',
|
|
13
|
+
EXPECTED_KEEP_CARDINALITY_MISMATCH: 'expected_keep_cardinality_mismatch',
|
|
14
|
+
EXPECTED_KEEP_SET_MISMATCH: 'expected_keep_set_mismatch',
|
|
15
|
+
EXPECTED_TAB_MISSING_FROM_INVENTORY_UNION: 'expected_tab_missing_from_inventory_union',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function canonicalizeTabId(tabId) {
|
|
19
|
+
if (typeof tabId === 'number') {
|
|
20
|
+
if (!Number.isSafeInteger(tabId)) throw new TypeError('Tab IDs supplied as numbers must be safe integers.');
|
|
21
|
+
return String(tabId);
|
|
22
|
+
}
|
|
23
|
+
if (typeof tabId !== 'string' || tabId.trim().length === 0) {
|
|
24
|
+
throw new TypeError('Tab IDs must be safe integers or nonblank opaque strings.');
|
|
25
|
+
}
|
|
26
|
+
if (tabId.length > 512) throw new RangeError('Opaque tab IDs must not exceed 512 characters.');
|
|
27
|
+
return tabId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalizeTabIds(tabIds, { label, maxItems }) {
|
|
31
|
+
if (!Array.isArray(tabIds)) throw new TypeError('Tab ID collections must be arrays.');
|
|
32
|
+
if (tabIds.length > maxItems) {
|
|
33
|
+
throw new RangeError(`${label} must not contain more than ${maxItems} tab IDs.`);
|
|
34
|
+
}
|
|
35
|
+
return tabIds.map(canonicalizeTabId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasDuplicates(tabIds) {
|
|
39
|
+
return new Set(tabIds).size !== tabIds.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasCrossTypeCollision(collections) {
|
|
43
|
+
const typesByCanonicalId = new Map();
|
|
44
|
+
for (const tabId of collections.flat()) {
|
|
45
|
+
const canonical = canonicalizeTabId(tabId);
|
|
46
|
+
const types = typesByCanonicalId.get(canonical) ?? new Set();
|
|
47
|
+
types.add(typeof tabId);
|
|
48
|
+
typesByCanonicalId.set(canonical, types);
|
|
49
|
+
}
|
|
50
|
+
return [...typesByCanonicalId.values()].some((types) => types.size > 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sameSet(left, right) {
|
|
54
|
+
if (left.length !== right.length) return false;
|
|
55
|
+
const rightSet = new Set(right);
|
|
56
|
+
return left.every((tabId) => rightSet.has(tabId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateApplyTabKeepSet({
|
|
60
|
+
expectedTabIds,
|
|
61
|
+
keepTabIds,
|
|
62
|
+
controllerInventory,
|
|
63
|
+
userInventory,
|
|
64
|
+
}) {
|
|
65
|
+
const expected = canonicalizeTabIds(expectedTabIds, { label: 'expectedTabIds', maxItems: 100 });
|
|
66
|
+
const keep = canonicalizeTabIds(keepTabIds, { label: 'keepTabIds', maxItems: 100 });
|
|
67
|
+
const controller = canonicalizeTabIds(controllerInventory?.tabIds, {
|
|
68
|
+
label: 'controllerInventory.tabIds',
|
|
69
|
+
maxItems: 1000,
|
|
70
|
+
});
|
|
71
|
+
const user = canonicalizeTabIds(userInventory?.tabIds, {
|
|
72
|
+
label: 'userInventory.tabIds',
|
|
73
|
+
maxItems: 1000,
|
|
74
|
+
});
|
|
75
|
+
const failureCodes = [];
|
|
76
|
+
const addFailure = (code) => {
|
|
77
|
+
if (!failureCodes.includes(code)) failureCodes.push(code);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (controllerInventory?.complete !== true) {
|
|
81
|
+
addFailure(APPLY_TAB_SET_FAILURE_CODES.CONTROLLER_INVENTORY_INCOMPLETE);
|
|
82
|
+
}
|
|
83
|
+
if (userInventory?.complete !== true) {
|
|
84
|
+
addFailure(APPLY_TAB_SET_FAILURE_CODES.USER_INVENTORY_INCOMPLETE);
|
|
85
|
+
}
|
|
86
|
+
if (expected.length === 0) addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_SET_EMPTY);
|
|
87
|
+
if (expected.length > 0 && keep.length === 0) {
|
|
88
|
+
addFailure(APPLY_TAB_SET_FAILURE_CODES.KEEP_SET_EMPTY_WITH_EXPECTED_TABS);
|
|
89
|
+
}
|
|
90
|
+
if (hasDuplicates(expected)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_EXPECTED_TAB_ID);
|
|
91
|
+
if (hasDuplicates(keep)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_KEEP_TAB_ID);
|
|
92
|
+
if (hasDuplicates(controller)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_CONTROLLER_TAB_ID);
|
|
93
|
+
if (hasDuplicates(user)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_USER_TAB_ID);
|
|
94
|
+
if (hasCrossTypeCollision([
|
|
95
|
+
expectedTabIds, keepTabIds, controllerInventory.tabIds, userInventory.tabIds,
|
|
96
|
+
])) addFailure(APPLY_TAB_SET_FAILURE_CODES.AMBIGUOUS_CROSS_TYPE_TAB_ID);
|
|
97
|
+
if (expected.length !== keep.length) {
|
|
98
|
+
addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_KEEP_CARDINALITY_MISMATCH);
|
|
99
|
+
}
|
|
100
|
+
if (!sameSet(expected, keep)) addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_KEEP_SET_MISMATCH);
|
|
101
|
+
|
|
102
|
+
const inventoryUnion = new Set([...controller, ...user]);
|
|
103
|
+
if (expected.some((tabId) => !inventoryUnion.has(tabId))) {
|
|
104
|
+
addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_TAB_MISSING_FROM_INVENTORY_UNION);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const safeToFinalize = failureCodes.length === 0;
|
|
108
|
+
return {
|
|
109
|
+
safeToFinalize,
|
|
110
|
+
failureCodes,
|
|
111
|
+
expectedCount: expected.length,
|
|
112
|
+
keepCount: keep.length,
|
|
113
|
+
controllerInventoryCount: controller.length,
|
|
114
|
+
userInventoryCount: user.length,
|
|
115
|
+
inventoryUnionCount: inventoryUnion.size,
|
|
116
|
+
canonicalKeepTabIds: safeToFinalize ? keep : [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
APPLY_TAB_SET_FAILURE_CODES,
|
|
122
|
+
canonicalizeTabId,
|
|
123
|
+
validateApplyTabKeepSet,
|
|
124
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
|
|
4
|
+
|
|
5
|
+
const APPLY_UPLOAD_STAGES = Object.freeze([...APPLY_CONTRACT.constants.applyUploadStages]);
|
|
6
|
+
const APPLY_UPLOAD_FAILURE_CODES = Object.freeze([
|
|
7
|
+
...APPLY_CONTRACT.constants.applyUploadFailureCodes,
|
|
8
|
+
]);
|
|
9
|
+
const APPLY_UPLOAD_CAPABILITIES = Object.freeze([
|
|
10
|
+
'semanticControlDiscovery',
|
|
11
|
+
'chooserArming',
|
|
12
|
+
'fileAttachment',
|
|
13
|
+
'committedFilenameInspection',
|
|
14
|
+
'parserFieldRecheck',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function validateApplyResumeUpload({ capabilities, events }) {
|
|
18
|
+
const failureCodes = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let expectedIndex = 0;
|
|
21
|
+
let lastPassedStage = null;
|
|
22
|
+
|
|
23
|
+
for (const capability of APPLY_UPLOAD_CAPABILITIES) {
|
|
24
|
+
if (capabilities[capability] !== true) failureCodes.push('upload_capability_unavailable');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const event of events) {
|
|
28
|
+
if (seen.has(event.stage)) {
|
|
29
|
+
failureCodes.push('upload_stage_duplicate');
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
seen.add(event.stage);
|
|
33
|
+
const index = APPLY_UPLOAD_STAGES.indexOf(event.stage);
|
|
34
|
+
const isExpectedStage = index === expectedIndex;
|
|
35
|
+
if (!isExpectedStage) failureCodes.push('upload_stage_out_of_order');
|
|
36
|
+
|
|
37
|
+
const hasFailureCode = Boolean(event.failureCode);
|
|
38
|
+
if (hasFailureCode) {
|
|
39
|
+
failureCodes.push(event.failureCode);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (event.outcome === 'failed' || hasFailureCode) {
|
|
43
|
+
if (!event.failureCode) failureCodes.push('upload_stage_missing');
|
|
44
|
+
} else if (isExpectedStage) {
|
|
45
|
+
expectedIndex += 1;
|
|
46
|
+
lastPassedStage = event.stage;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (expectedIndex !== APPLY_UPLOAD_STAGES.length) failureCodes.push('upload_stage_missing');
|
|
51
|
+
const uniqueFailureCodes = [...new Set(failureCodes)];
|
|
52
|
+
return {
|
|
53
|
+
safeToClaimAttachment: uniqueFailureCodes.length === 0,
|
|
54
|
+
failureCodes: uniqueFailureCodes,
|
|
55
|
+
lastPassedStage,
|
|
56
|
+
completedStageCount: expectedIndex,
|
|
57
|
+
requiredStageCount: APPLY_UPLOAD_STAGES.length,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
APPLY_UPLOAD_CAPABILITIES,
|
|
63
|
+
APPLY_UPLOAD_FAILURE_CODES,
|
|
64
|
+
APPLY_UPLOAD_STAGES,
|
|
65
|
+
validateApplyResumeUpload,
|
|
66
|
+
};
|
package/mcp/apply-tools.js
CHANGED
|
@@ -5,6 +5,12 @@ const { apiRequest } = require('../lib/client');
|
|
|
5
5
|
const { prepareResume, verifyPreparedResume } = require('../lib/agent');
|
|
6
6
|
const { lintApplicationText } = require('../lib/application-text');
|
|
7
7
|
const { diagnoseLocalPath, ERRNO_PATTERN } = require('../lib/path-diagnostics');
|
|
8
|
+
const { validateApplyTabKeepSet } = require('../lib/apply-tab-set');
|
|
9
|
+
const {
|
|
10
|
+
APPLY_UPLOAD_FAILURE_CODES,
|
|
11
|
+
APPLY_UPLOAD_STAGES,
|
|
12
|
+
validateApplyResumeUpload,
|
|
13
|
+
} = require('../lib/apply-upload');
|
|
8
14
|
const { isIso3166Alpha2 } = require('../lib/iso-country-codes');
|
|
9
15
|
const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
|
|
10
16
|
|
|
@@ -22,6 +28,8 @@ const APPLY_SURFACE_EVIDENCE_TYPES = APPLY_CONTRACT.constants.applySurfaceEviden
|
|
|
22
28
|
const APPLY_SURFACE_OWNERSHIP_STATES = APPLY_CONTRACT.constants.applySurfaceOwnershipStates;
|
|
23
29
|
const APPLY_SUBMISSION_EVIDENCE_TYPES = APPLY_CONTRACT.constants.applySubmissionEvidenceTypes;
|
|
24
30
|
const APPLY_SUBMISSION_EVIDENCE_SOURCES = APPLY_CONTRACT.constants.applySubmissionEvidenceSources;
|
|
31
|
+
const APPLY_EXECUTION_RECOVERY_ELIGIBILITY_CODES = APPLY_CONTRACT.constants.applyExecutionRecoveryEligibilityCodes;
|
|
32
|
+
const APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES = APPLY_CONTRACT.constants.applyHandoffReconciliationClassifications;
|
|
25
33
|
const APPLY_BATCH_MAX_MEMBERS = 100;
|
|
26
34
|
const APPLY_BATCH_MAX_CHECKPOINTS_PER_REQUEST = 20;
|
|
27
35
|
const APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT = 25;
|
|
@@ -32,6 +40,12 @@ const SAFE_IDEMPOTENCY_KEY = /^[\x20-\x7e]+$/;
|
|
|
32
40
|
const iso3166Alpha2Schema = z.string().regex(/^[A-Za-z]{2}$/).refine(isIso3166Alpha2, {
|
|
33
41
|
message: 'Expected an ISO 3166-1 alpha-2 country code',
|
|
34
42
|
});
|
|
43
|
+
const opaqueTabIdSchema = z.union([
|
|
44
|
+
z.number().int().safe(),
|
|
45
|
+
z.string().min(1).max(512).refine((value) => value.trim().length > 0, {
|
|
46
|
+
message: 'Expected a nonblank opaque tab ID',
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
35
49
|
const applyExecutionDispositionSchema = z.object({
|
|
36
50
|
jobId: z.number().int().min(1),
|
|
37
51
|
classification: z.enum(APPLY_EXECUTION_ACCESS_CLASSIFICATIONS),
|
|
@@ -44,6 +58,133 @@ const applyExecutionDispositionSchema = z.object({
|
|
|
44
58
|
probeOnlyNoDraft: z.boolean().optional(),
|
|
45
59
|
browserSurface: z.enum(APPLY_BROWSER_SURFACES),
|
|
46
60
|
}).strict();
|
|
61
|
+
const recoverableCandidateSchema = z.object({
|
|
62
|
+
candidateId: z.number().int().min(1),
|
|
63
|
+
jobId: z.number().int().min(1),
|
|
64
|
+
queuePosition: z.number().int().min(0),
|
|
65
|
+
eligibilityCode: z.enum(APPLY_EXECUTION_RECOVERY_ELIGIBILITY_CODES),
|
|
66
|
+
}).strict();
|
|
67
|
+
const recoverableExecutionSourceSchema = z.object({
|
|
68
|
+
sourceExecutionId: z.number().int().min(1),
|
|
69
|
+
sourceSnapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
70
|
+
recoverableUntil: z.string().datetime(),
|
|
71
|
+
candidates: z.array(recoverableCandidateSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
72
|
+
}).strict().refine(({ candidates }) => (
|
|
73
|
+
new Set(candidates.map(({ candidateId }) => candidateId)).size === candidates.length
|
|
74
|
+
), { message: 'recoverable source candidate IDs must be unique' });
|
|
75
|
+
const recoverableExecutionsResponseSchema = z.object({
|
|
76
|
+
success: z.literal(true),
|
|
77
|
+
sources: z.array(recoverableExecutionSourceSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
78
|
+
}).strict().refine(({ sources }) => (
|
|
79
|
+
new Set(sources.map(({ sourceExecutionId }) => sourceExecutionId)).size === sources.length
|
|
80
|
+
), { message: 'recoverable source execution IDs must be unique' });
|
|
81
|
+
// Discovery responses are strict and value-free so unexpected fields never enter agent context.
|
|
82
|
+
// Mutation responses permit additive top-level metadata, but keep safety-critical nested rows strict.
|
|
83
|
+
const exactRecoveryResponseSchema = z.object({
|
|
84
|
+
success: z.literal(true),
|
|
85
|
+
replay: z.boolean(),
|
|
86
|
+
execution: z.object({
|
|
87
|
+
id: z.number().int().min(1),
|
|
88
|
+
mode: z.literal('recover_exact_members'),
|
|
89
|
+
}).passthrough(),
|
|
90
|
+
assertedCandidateIds: z.array(z.number().int().min(1)).max(APPLY_EXECUTION_MAX_TARGET),
|
|
91
|
+
eligibleCandidateIds: z.array(z.number().int().min(1)).max(APPLY_EXECUTION_MAX_TARGET),
|
|
92
|
+
eligibility: z.array(recoverableCandidateSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
93
|
+
}).passthrough();
|
|
94
|
+
|
|
95
|
+
function validateExactRecoveryResponse(input, response) {
|
|
96
|
+
const requested = [...input.candidateIds].sort((a, b) => a - b);
|
|
97
|
+
const asserted = [...response.assertedCandidateIds].sort((a, b) => a - b);
|
|
98
|
+
const eligible = [...response.eligibleCandidateIds].sort((a, b) => a - b);
|
|
99
|
+
const eligibilityIds = response.eligibility
|
|
100
|
+
.map(({ candidateId }) => candidateId)
|
|
101
|
+
.sort((a, b) => a - b);
|
|
102
|
+
const matchesRequested = (values) => (
|
|
103
|
+
values.length === requested.length
|
|
104
|
+
&& values.every((value, index) => value === requested[index])
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
new Set(response.assertedCandidateIds).size !== response.assertedCandidateIds.length
|
|
108
|
+
|| new Set(response.eligibleCandidateIds).size !== response.eligibleCandidateIds.length
|
|
109
|
+
|| new Set(eligibilityIds).size !== eligibilityIds.length
|
|
110
|
+
|| !matchesRequested(asserted)
|
|
111
|
+
|| !matchesRequested(eligible)
|
|
112
|
+
|| !matchesRequested(eligibilityIds)
|
|
113
|
+
|| response.eligibility.some(({ eligibilityCode }) => eligibilityCode !== 'recoverable')
|
|
114
|
+
) {
|
|
115
|
+
throw new Error('Exact recovery response does not match the requested candidate set.');
|
|
116
|
+
}
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
119
|
+
const handoffClaimMemberSchema = z.object({
|
|
120
|
+
memberId: z.number().int().min(1),
|
|
121
|
+
classification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES),
|
|
122
|
+
}).strict();
|
|
123
|
+
const handoffMemberSchema = z.object({
|
|
124
|
+
handoffId: z.number().int().min(1),
|
|
125
|
+
ordinal: z.number().int().min(0),
|
|
126
|
+
batchId: z.number().int().min(1),
|
|
127
|
+
memberId: z.number().int().min(1),
|
|
128
|
+
runId: z.number().int().min(1),
|
|
129
|
+
memberVersion: z.number().int().min(1),
|
|
130
|
+
inspectionEpoch: z.number().int().min(0),
|
|
131
|
+
reconciliationClassification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES).nullable(),
|
|
132
|
+
reconciliationResultStatus: z.string().max(100).nullable(),
|
|
133
|
+
}).strict();
|
|
134
|
+
const handoffListResponseSchema = z.object({
|
|
135
|
+
success: z.literal(true),
|
|
136
|
+
executionId: z.number().int().min(1),
|
|
137
|
+
handoffs: z.array(z.object({
|
|
138
|
+
id: z.number().int().min(1),
|
|
139
|
+
executionId: z.number().int().min(1),
|
|
140
|
+
orderedMemberSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
141
|
+
generation: z.number().int().min(1),
|
|
142
|
+
status: z.enum(['active', 'partially_reconciled']),
|
|
143
|
+
claimedAt: z.string().datetime().nullable(),
|
|
144
|
+
expiresAt: z.string().datetime(),
|
|
145
|
+
members: z.array(handoffMemberSchema).min(1).max(APPLY_EXECUTION_MAX_TARGET),
|
|
146
|
+
}).strict()).max(APPLY_EXECUTION_MAX_TARGET),
|
|
147
|
+
}).strict();
|
|
148
|
+
|
|
149
|
+
function validateHandoffListResponse(requestedExecutionId, response) {
|
|
150
|
+
const handoffIds = response.handoffs.map(({ id }) => id);
|
|
151
|
+
if (
|
|
152
|
+
response.executionId !== requestedExecutionId
|
|
153
|
+
|| new Set(handoffIds).size !== handoffIds.length
|
|
154
|
+
|| response.handoffs.some((handoff) => (
|
|
155
|
+
handoff.executionId !== requestedExecutionId
|
|
156
|
+
|| new Set(handoff.members.map(({ memberId }) => memberId)).size !== handoff.members.length
|
|
157
|
+
|| handoff.members.some((member) => member.handoffId !== handoff.id)
|
|
158
|
+
))
|
|
159
|
+
) {
|
|
160
|
+
throw new Error('Review handoff response does not match the requested execution and receipt bindings.');
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
const handoffClaimResponseSchema = z.object({
|
|
165
|
+
success: z.literal(true),
|
|
166
|
+
handoffId: z.number().int().min(1),
|
|
167
|
+
executionId: z.number().int().min(1),
|
|
168
|
+
orderedMemberSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
169
|
+
members: z.array(handoffClaimMemberSchema).min(1).max(APPLY_EXECUTION_MAX_TARGET),
|
|
170
|
+
transition: z.literal('claimed'),
|
|
171
|
+
}).passthrough();
|
|
172
|
+
|
|
173
|
+
function validateHandoffClaimResponse(request, response) {
|
|
174
|
+
const requestedMembers = request.members.map(({ memberId, classification }) => `${memberId}:${classification}`);
|
|
175
|
+
const returnedMembers = response.members.map(({ memberId, classification }) => `${memberId}:${classification}`);
|
|
176
|
+
if (
|
|
177
|
+
response.handoffId !== request.handoffId
|
|
178
|
+
|| response.executionId !== request.executionId
|
|
179
|
+
|| response.orderedMemberSetHash !== request.orderedMemberSetHash
|
|
180
|
+
|| new Set(returnedMembers).size !== returnedMembers.length
|
|
181
|
+
|| requestedMembers.length !== returnedMembers.length
|
|
182
|
+
|| !requestedMembers.every((member) => returnedMembers.includes(member))
|
|
183
|
+
) {
|
|
184
|
+
throw new Error('Handoff claim response does not match the requested handoff and member classifications.');
|
|
185
|
+
}
|
|
186
|
+
return response;
|
|
187
|
+
}
|
|
47
188
|
|
|
48
189
|
const truthCertificationCommon = {
|
|
49
190
|
batchId: z.number().int().min(1),
|
|
@@ -121,7 +262,7 @@ const startApplyRunSchema = z.object({
|
|
|
121
262
|
}
|
|
122
263
|
});
|
|
123
264
|
|
|
124
|
-
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.
|
|
265
|
+
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.6 / skill 4.5.0 reliability gate: recover active work first. After full local context loss, list recoverable executions, show only stable job identity, obtain explicit confirmation of the exact candidate set, and call exact-member recovery without substitutions. Treat recovered tab presence, form state, and mutation authority as three separate facts; reacquire a fresh browser binding and inspection epoch before mutation. Before resolving broad submission statements, list active review handoffs for the execution; use only an explicit receipt or the sole returned active receipt, classify every member as detected, user_confirmed, unresolved, or contradictory, and claim that exact handoff before writing outcomes. Use provider-specific positive success evidence; an unchanged URL or title is never negative evidence. Validate an exact expected browser keep set locally before finalization. For resume uploads, negotiate the browser surface capabilities, identify the semantic control, arm the chooser before clicking, attach the immediately verified file, prove the user-facing filename committed, and recheck parser-modified fields. Use compact snapshots and server-provided mutability. Preserve user-edited and unknown non-empty fields. Never reopen parked work without explicit user resumption. Never click Submit.';
|
|
125
266
|
|
|
126
267
|
function registerApplyTools(
|
|
127
268
|
server,
|
|
@@ -143,6 +284,8 @@ function registerApplyTools(
|
|
|
143
284
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
|
|
144
285
|
)
|
|
145
286
|
);
|
|
287
|
+
const discoveredHandoffBindings = new Map();
|
|
288
|
+
const discoveredHandoffIdsByExecution = new Map();
|
|
146
289
|
server.tool(
|
|
147
290
|
'trackly_get_apply_queue',
|
|
148
291
|
'Get the deterministic queue of jobs the user already approved by saving as check later. Do not rescore or veto these jobs.',
|
|
@@ -271,6 +414,101 @@ function registerApplyTools(
|
|
|
271
414
|
), 'Failed to fetch apply execution')
|
|
272
415
|
);
|
|
273
416
|
|
|
417
|
+
server.tool(
|
|
418
|
+
'trackly_list_recoverable_apply_executions',
|
|
419
|
+
'List bounded, value-free exact-member recovery candidates after local context loss. Show the stable job identities to the user and obtain explicit confirmation before recovery; never infer or substitute candidates.',
|
|
420
|
+
{},
|
|
421
|
+
wrapTool(async () => recoverableExecutionsResponseSchema.parse(await applyControlRequest(
|
|
422
|
+
'GET', '/api/jobscout/apply/executions/recoverable',
|
|
423
|
+
)), 'Failed to list recoverable apply executions')
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
server.tool(
|
|
427
|
+
'trackly_recover_exact_apply_members',
|
|
428
|
+
'Create an exact-member recovery execution from one confirmed source snapshot. The asserted candidate set is immutable and replacements are forbidden.',
|
|
429
|
+
{
|
|
430
|
+
sourceExecutionId: z.number().int().min(1),
|
|
431
|
+
sourceSnapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
432
|
+
candidateIds: z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET)
|
|
433
|
+
.refine((values) => new Set(values).size === values.length, {
|
|
434
|
+
message: 'candidateIds must be unique',
|
|
435
|
+
}),
|
|
436
|
+
explicitExactSetConfirmation: z.literal(true),
|
|
437
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
438
|
+
},
|
|
439
|
+
wrapTool(async ({ idempotencyKey, ...input }) => validateExactRecoveryResponse(
|
|
440
|
+
input,
|
|
441
|
+
exactRecoveryResponseSchema.parse(await applyControlRequest('POST', '/api/jobscout/apply/executions/recover', {
|
|
442
|
+
mode: 'recover_exact_members',
|
|
443
|
+
...input,
|
|
444
|
+
}, idempotencyKey)),
|
|
445
|
+
), 'Failed to recover exact apply members')
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
server.tool(
|
|
449
|
+
'trackly_list_apply_review_handoffs',
|
|
450
|
+
'List active, nonexpired review-handoff receipts for one execution after context loss. Returns only stable IDs, lifecycle metadata, and member bindings; never browser values, URLs, or local paths.',
|
|
451
|
+
{ executionId: z.number().int().min(1) },
|
|
452
|
+
wrapTool(async ({ executionId }) => {
|
|
453
|
+
const response = validateHandoffListResponse(
|
|
454
|
+
executionId,
|
|
455
|
+
handoffListResponseSchema.parse(await applyControlRequest(
|
|
456
|
+
'GET', `/api/jobscout/apply/executions/${executionId}/review-handoffs`,
|
|
457
|
+
)),
|
|
458
|
+
);
|
|
459
|
+
for (const handoffId of discoveredHandoffIdsByExecution.get(executionId) || []) {
|
|
460
|
+
discoveredHandoffBindings.delete(handoffId);
|
|
461
|
+
}
|
|
462
|
+
const discoveredHandoffIds = new Set();
|
|
463
|
+
for (const handoff of response.handoffs) {
|
|
464
|
+
discoveredHandoffBindings.set(handoff.id, {
|
|
465
|
+
executionId: handoff.executionId,
|
|
466
|
+
orderedMemberSetHash: handoff.orderedMemberSetHash,
|
|
467
|
+
memberIds: handoff.members.map(({ memberId }) => memberId),
|
|
468
|
+
});
|
|
469
|
+
discoveredHandoffIds.add(handoff.id);
|
|
470
|
+
}
|
|
471
|
+
discoveredHandoffIdsByExecution.set(executionId, discoveredHandoffIds);
|
|
472
|
+
return response;
|
|
473
|
+
}, 'Failed to list apply review handoffs')
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
server.tool(
|
|
477
|
+
'trackly_claim_apply_review_handoff',
|
|
478
|
+
'Claim one exact, unambiguous review-handoff group and classify every member before grouped submission reconciliation. Never use this tool to infer submission or replace success-page evidence or explicit user confirmation.',
|
|
479
|
+
{
|
|
480
|
+
handoffId: z.number().int().min(1),
|
|
481
|
+
members: z.array(z.object({
|
|
482
|
+
memberId: z.number().int().min(1),
|
|
483
|
+
classification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES),
|
|
484
|
+
}).strict()).min(1).max(APPLY_EXECUTION_MAX_TARGET)
|
|
485
|
+
.refine((values) => new Set(values.map(({ memberId }) => memberId)).size === values.length, {
|
|
486
|
+
message: 'members must contain unique memberId values',
|
|
487
|
+
}),
|
|
488
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
489
|
+
},
|
|
490
|
+
wrapTool(async ({ handoffId, idempotencyKey, members }) => {
|
|
491
|
+
const binding = discoveredHandoffBindings.get(handoffId);
|
|
492
|
+
if (!binding) {
|
|
493
|
+
throw new Error('Review handoff must be selected from the latest discovery response.');
|
|
494
|
+
}
|
|
495
|
+
const { memberIds, ...receiptBinding } = binding;
|
|
496
|
+
const requestedMemberIds = new Set(members.map(({ memberId }) => memberId));
|
|
497
|
+
if (
|
|
498
|
+
requestedMemberIds.size !== memberIds.length
|
|
499
|
+
|| memberIds.some((memberId) => !requestedMemberIds.has(memberId))
|
|
500
|
+
) {
|
|
501
|
+
throw new Error('Handoff claim must classify every discovered member exactly once.');
|
|
502
|
+
}
|
|
503
|
+
return validateHandoffClaimResponse(
|
|
504
|
+
{ handoffId, members, ...receiptBinding },
|
|
505
|
+
handoffClaimResponseSchema.parse(await applyControlRequest(
|
|
506
|
+
'POST', `/api/jobscout/apply/review-handoffs/${handoffId}/claim`, { members }, idempotencyKey,
|
|
507
|
+
)),
|
|
508
|
+
);
|
|
509
|
+
}, 'Failed to claim apply review handoff')
|
|
510
|
+
);
|
|
511
|
+
|
|
274
512
|
server.tool(
|
|
275
513
|
'trackly_get_apply_execution_snapshot',
|
|
276
514
|
'Fetch a compact, bounded projection for one Apply execution. Request only the current members and profile keys needed for the visible form. The response owns mutability, allowed operations, milestones, lease timing, and progress.',
|
|
@@ -757,6 +995,7 @@ function registerApplyTools(
|
|
|
757
995
|
'Record review readiness or a user-confirmed outcome. Before handoff use literal outcome=review_ready and verify awaiting_manual_submit. Mark submitted with literal outcome=submitted only after a success page or explicit user confirmation.',
|
|
758
996
|
{
|
|
759
997
|
runId: z.number().int().min(1),
|
|
998
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
760
999
|
batchId: z.number().int().min(1).optional(),
|
|
761
1000
|
memberId: z.number().int().min(1).optional(),
|
|
762
1001
|
inspectionEpoch: z.number().int().min(0).optional(),
|
|
@@ -764,13 +1003,19 @@ function registerApplyTools(
|
|
|
764
1003
|
outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
|
|
765
1004
|
confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
|
|
766
1005
|
},
|
|
767
|
-
wrapTool(async ({ runId, ...body }) =>
|
|
1006
|
+
wrapTool(async ({ runId, idempotencyKey, ...body }) => applyControlRequest(
|
|
1007
|
+
'POST',
|
|
1008
|
+
`/api/jobscout/apply/runs/${runId}/outcome`,
|
|
1009
|
+
body,
|
|
1010
|
+
idempotencyKey
|
|
1011
|
+
), 'Failed to record application outcome')
|
|
768
1012
|
);
|
|
769
1013
|
|
|
770
1014
|
server.tool(
|
|
771
1015
|
'trackly_record_application_outcomes',
|
|
772
1016
|
'Bulk-record up to 20 leased, batch-bound review or user-confirmed outcomes. Before handoff every item uses literal outcome=review_ready and every recorded run must return awaiting_manual_submit. After manual confirmation use literal outcome=submitted. Each member returns recorded or a stable conflict without hiding sibling results.',
|
|
773
1017
|
{
|
|
1018
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
774
1019
|
outcomes: z.array(z.object({
|
|
775
1020
|
runId: z.number().int().min(1),
|
|
776
1021
|
batchId: z.number().int().min(1),
|
|
@@ -779,16 +1024,17 @@ function registerApplyTools(
|
|
|
779
1024
|
leaseToken: z.string().min(1).max(1024),
|
|
780
1025
|
outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
|
|
781
1026
|
confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
|
|
782
|
-
})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS)
|
|
1027
|
+
})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS).refine(
|
|
1028
|
+
(values) => new Set(values.map(({ runId }) => runId)).size === values.length,
|
|
1029
|
+
{ message: 'outcomes must contain unique runId values' }
|
|
1030
|
+
),
|
|
783
1031
|
},
|
|
784
1032
|
wrapTool(
|
|
785
|
-
async (params) =>
|
|
1033
|
+
async ({ idempotencyKey, ...params }) => applyControlRequest(
|
|
786
1034
|
'POST',
|
|
787
1035
|
'/api/jobscout/apply/outcomes/bulk',
|
|
788
1036
|
params,
|
|
789
|
-
|
|
790
|
-
false,
|
|
791
|
-
MCP_USER_AGENT
|
|
1037
|
+
idempotencyKey
|
|
792
1038
|
),
|
|
793
1039
|
'Failed to record bulk application outcomes'
|
|
794
1040
|
)
|
|
@@ -835,6 +1081,44 @@ function registerApplyTools(
|
|
|
835
1081
|
wrapTool(async ({ exactPath, originalErrno }) => diagnoseLocalPath(exactPath, { originalErrno }), 'Local path diagnosis failed')
|
|
836
1082
|
);
|
|
837
1083
|
|
|
1084
|
+
server.tool(
|
|
1085
|
+
'trackly_validate_apply_tab_keep_set',
|
|
1086
|
+
'Locally validate a caller-supplied session-finalizer keep set against complete controller and user tab inventories. This pure helper never enumerates, focuses, closes, hands off, or finalizes browser tabs and never sends tab IDs to Trackly.',
|
|
1087
|
+
{
|
|
1088
|
+
expectedTabIds: z.array(opaqueTabIdSchema).min(1).max(100),
|
|
1089
|
+
keepTabIds: z.array(opaqueTabIdSchema).max(100),
|
|
1090
|
+
controllerInventory: z.object({
|
|
1091
|
+
complete: z.boolean(),
|
|
1092
|
+
tabIds: z.array(opaqueTabIdSchema).max(1000),
|
|
1093
|
+
}).strict(),
|
|
1094
|
+
userInventory: z.object({
|
|
1095
|
+
complete: z.boolean(),
|
|
1096
|
+
tabIds: z.array(opaqueTabIdSchema).max(1000),
|
|
1097
|
+
}).strict(),
|
|
1098
|
+
},
|
|
1099
|
+
wrapTool(async (params) => validateApplyTabKeepSet(params), 'Apply tab keep-set validation failed')
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
server.tool(
|
|
1103
|
+
'trackly_validate_apply_resume_upload',
|
|
1104
|
+
'Locally validate the browser adapter capabilities and ordered, value-free proof stages required to claim one resume attachment. This helper does not open a chooser, read a local path, control the browser, or send values to Trackly.',
|
|
1105
|
+
{
|
|
1106
|
+
capabilities: z.object({
|
|
1107
|
+
semanticControlDiscovery: z.boolean(),
|
|
1108
|
+
chooserArming: z.boolean(),
|
|
1109
|
+
fileAttachment: z.boolean(),
|
|
1110
|
+
committedFilenameInspection: z.boolean(),
|
|
1111
|
+
parserFieldRecheck: z.boolean(),
|
|
1112
|
+
}).strict(),
|
|
1113
|
+
events: z.array(z.object({
|
|
1114
|
+
stage: z.enum(APPLY_UPLOAD_STAGES),
|
|
1115
|
+
outcome: z.enum(['passed', 'failed']),
|
|
1116
|
+
failureCode: z.enum(APPLY_UPLOAD_FAILURE_CODES).optional(),
|
|
1117
|
+
}).strict()).max(APPLY_UPLOAD_STAGES.length),
|
|
1118
|
+
},
|
|
1119
|
+
wrapTool(async (params) => validateApplyResumeUpload(params), 'Apply resume upload validation failed')
|
|
1120
|
+
);
|
|
1121
|
+
|
|
838
1122
|
server.tool(
|
|
839
1123
|
'trackly_verify_prepared_resume',
|
|
840
1124
|
'Immediately before attachment, recompute the prepared resume fingerprint, validate its run and expiration, and lock the confirmed file read-only.',
|
|
@@ -857,11 +1141,20 @@ function registerApplyTools(
|
|
|
857
1141
|
messages: [{
|
|
858
1142
|
role: 'user',
|
|
859
1143
|
content: { type: 'text', text: APPLY_RELIABILITY_PROMPT },
|
|
1144
|
+
}, {
|
|
1145
|
+
role: 'user',
|
|
1146
|
+
content: { type: 'text', text: 'Only active=true identifies resumable execution work. Active=false and preserved=true is terminal read-only reconciliation evidence.' },
|
|
1147
|
+
}, {
|
|
1148
|
+
role: 'user',
|
|
1149
|
+
content: {
|
|
1150
|
+
type: 'text',
|
|
1151
|
+
text: 'Protocol 3.6.0 reliability gate for new work: require MCP contract 3.7.1 and skill 4.5.0. After complete local context loss, list bounded recovery candidates, obtain explicit confirmation of the exact set, and recover only that set. Treat tab recovery, form-state recovery, and mutation authority as independent. List active handoff receipts for the execution before resolving grouped submission statements; use the named receipt or the sole returned active receipt, classify every member, and claim that receipt before recording outcomes. Validate tab keep sets and resume upload stages locally. Never send raw browser values or click Submit.',
|
|
1152
|
+
},
|
|
860
1153
|
}, {
|
|
861
1154
|
role: 'user',
|
|
862
1155
|
content: {
|
|
863
1156
|
type: 'text',
|
|
864
|
-
text: '
|
|
1157
|
+
text: 'Legacy protocol compatibility gate: require the fetched compatibleSkillMinimumVersion or newer for all new work and use protocol 3.6.0 as specified above. Read the Apply protocol first. Only protocol 3.5 or newer with the compact-snapshot capability may call trackly_get_apply_execution_snapshot or the parked-member resume and execution-resume approval tools. An already-active protocol 3.4 execution is read-only legacy recovery: use only its published get or stop tools and never mutate its browser forms. Only when the fetched protocol is 3.4 or newer call trackly_get_active_apply_execution before legacy batch recovery, including when accessible execution is disabled. For protocol 3.3, skip the execution endpoint and recover the already-active immutable fixed batch directly; protocol 3.2 remains valid only for an already-active explicit legacy single run. A disabled rollout may preserve an active execution: recover it read-only and use only get or stop tools until the capability is enabled; never start, advance, or record dispositions while disabled. If disabled and no execution is active, use the legacy fixed-batch path. Recover every entry in execution.unresolvedWaves in ascending waveOrder; an older unresolved wave remains part of recovery after a replacement wave exists, and execution.currentWave is only the latest scheduling identity, never the complete recovery set. For “fill/apply to the next N,” recover or start one complete_next_n_accessible execution with target 1–20 and follow only the server nextAction and authoritative funnel. If the requested N differs from the active target, explain the mismatch, obtain explicit confirmation, stop the old execution with reason target_changed, refetch its terminal state, then start the new target. If an immutable fixed batch is active when the user requests complete_next_n_accessible, explain the incompatible mode and summarize any review-ready, submitted, or unresolved work before browser mutation. Resume that exact fixed batch when the user chooses to finish it. If the user instead says to start fresh, leave, replace, discard, or otherwise abandon the old batch, treat that statement as explicit cancellation confirmation: refetch the latest batch revision, call trackly_cancel_apply_batch with reason user_requested_restart and a fresh idempotency key, refetch until no active fixed batch remains, preserve every existing browser tab without mutation, and start the requested accessible execution in the same turn. Never wait for batch expiry and never create a scheduled continuation merely to escape an obsolete batch. If cancellation reports submission_in_progress, preserve everything and stop for the user; do not cancel or start replacement work. If the user asks to stop, call trackly_stop_apply_execution with reason user_requested and refetch its terminal state. Continue immutable child waves from the original recent-first snapshot until the authoritative backend funnel says targetReached, the queue is exhausted, or the user stops. Treat achievementCount and target-capped completed as the cumulative target authority. durablyReviewReady and submitted are current operator projections only; never add them together locally to reconstruct completion. Accessible drafts awaiting answers and forms currently being filled occupy target slots; authentication, account creation, OTP, pre-form CAPTCHA, exclusions, manual-only, conflicts, and revocations do not. Record only typed value-free live-probe dispositions. Advance only when no current-wave member remains unclassified queued or inspecting. Never calculate replacements or progress locally. For an explicit “inspect the next N records” request, use the existing fixed immutable batch and never replenish it; if a different accessible execution is active, confirm the intent change with the user, stop that execution with reason target_changed, refetch its terminal state, then recover or create the fixed batch. A cache hint may prioritize a live minimal non-mutating probe but never authorizes private-data entry or replaces that probe. After a redirect or contradictory observation, report only the fresh live disposition with its exact binding and let the backend invalidate its own hint. Preserve every user-edited or unknown non-empty field through the local provenance ledger. Never submit.',
|
|
865
1158
|
},
|
|
866
1159
|
}, {
|
|
867
1160
|
role: 'user',
|